diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..d9ba9502 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view +# https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt + +72c7e3f4e34d2ed474b1399f2a7ab427fb6f3b14 +d837112369c0193def1c27636da1b9a8df5c98f3 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b0cfea28 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.php_cs.dist export-ignore +/.infection.json.dist export-ignore +/.phpunit.xml.dist export-ignore +/examples export-ignore +/tests export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..499b1123 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +- [ ] I've run the tests with `vendor/bin/phpunit` +- [ ] None of the tests were found failing +- [ ] I've seen the coverage report at `build/coverage/index.html` +- [ ] Not a single line left uncovered by tests +- [ ] Any coding standards issues were fixed with `vendor/bin/php-cs-fixer fix` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..97ddf0db --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/cs.yaml b/.github/workflows/cs.yaml new file mode 100644 index 00000000..2d136cbb --- /dev/null +++ b/.github/workflows/cs.yaml @@ -0,0 +1,47 @@ +name: Code Style + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: + - '8.3' + + name: PHP ${{ matrix.php-version }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: + coverage: pcov + tools: composer:v2 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} + restore-keys: | + composer-${{ matrix.php-version }}- + composer- + + - name: Install dependencies + run: | + composer update --prefer-dist --no-interaction --no-progress ${{ matrix.dependencies }} + + - name: Check code style + run: | + php vendor/bin/php-cs-fixer --using-cache=no --diff --dry-run --stop-on-violation --verbose fix + diff --git a/.github/workflows/mt.yaml b/.github/workflows/mt.yaml new file mode 100644 index 00000000..4846c7cd --- /dev/null +++ b/.github/workflows/mt.yaml @@ -0,0 +1,50 @@ +name: Mutation Testing + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: + - '8.4' + dependencies: [''] + + name: PHP ${{ matrix.php-version }} ${{ matrix.dependencies }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: + coverage: pcov + tools: composer:v2 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} + restore-keys: | + composer-${{ matrix.php-version }}- + composer- + + - name: Install dependencies + run: | + composer update --prefer-dist --no-interaction --no-progress ${{ matrix.dependencies }} + composer dump-autoload --optimize + + - name: Run mutation testing + run: | + php vendor/bin/infection --min-msi=80 --min-covered-msi=80 --threads=$(nproc) --no-progress + diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..068ffbda --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,61 @@ +# yamllint disable rule:line-length +# yamllint disable rule:braces + +name: CI + +on: + pull_request: + push: + branches: + - master + - main + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: + - '8.2' + - '8.3' + - '8.4' + include: + - { php-version: '8.2', dependencies: '--prefer-lowest', legend: 'with lowest dependencies' } + + name: PHP ${{ matrix.php-version }} ${{ matrix.legend }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('composer.*') }}-${{ matrix.composer-flags }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('composer.*') }}- + composer-${{ runner.os }}-${{ matrix.php-version }}- + composer-${{ runner.os }}- + composer- + + - name: Install dependencies + run: | + composer update --prefer-dist --no-interaction --no-progress ${{ matrix.dependencies }} + composer dump-autoload --optimize + + - name: Run tests + run: | + php vendor/bin/phpunit --migrate-configuration || true + php vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 072705cd..cfa1a18f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -.idea/ +.?* vendor/ composer.lock diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..77312786 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,62 @@ +setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR2' => true, + 'psr_autoloading' => true, + 'no_unreachable_default_argument_value' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_order' => true, + 'semicolon_after_instruction' => true, + 'whitespace_after_comma_in_array' => true, + 'header_comment' => ['header' => $header], + 'php_unit_construct' => true, + 'php_unit_dedicate_assert' => true, + 'php_unit_dedicate_assert_internal_type' => true, + 'php_unit_expectation' => true, + 'php_unit_mock_short_will_return' => true, + 'php_unit_mock' => true, + 'php_unit_namespaced' => true, + 'php_unit_no_expectation_annotation' => true, + "phpdoc_order_by_value" => ['annotations' => ['covers']], + 'php_unit_set_up_tear_down_visibility' => true, + 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], + 'no_whitespace_in_blank_line' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'array_syntax' => ['syntax' => 'short'], + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'binary_operator_spaces' => ['default' => 'at_least_single_space'], + ]) + ->setIndent("\t") + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__) + ->append([__FILE__]) + ) +; + + +return $config; diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3e71091d..00000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: php -php: - - 5.3 - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - 7.1 - -before_script: - - composer install - -script: ./vendor/bin/phpunit diff --git a/CHANGELOG b/CHANGELOG deleted file mode 100644 index c3ecef9f..00000000 --- a/CHANGELOG +++ /dev/null @@ -1,38 +0,0 @@ -* 1.2.1 (2016-12-12) - - * Remove content injection from $_GET. - * Add PHP 5.6, 7.0, 7.1 to Travis file. - -* 1.2 (2016-06-11) - - * Added "ESCAPE_BY_DEFAULT" setting for context-aware auto-escaping. - * Made "Context" work with plain objects. - * "escape" now uses "htmlentities". - * Fixed "escape_now". - -* 1.1 (2015-06-01) - - * New tags: "paginate", "unless", "ifchanged" were added - * Added support for "for in (range)" syntax - * Added support for multiple conditions in if statements - * Added support for hashes/objects in for loops - -* 1.0 (2014-09-07) - - * Add namespaces - * Add composer support - * Implement new standard filters - * Add 'raw' tag - -* 0.9.2 (2012-08-15) - - * context->set allows now global vars - * Allow Templatenames with Fileextension - * Tag 'extends' supports now multiple inheritance - * Clean up code, change all variables and methods to camelCase - - -* 0.9.1 (2012-05-12) - - * added the extends and block filter - * Initial release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c2143dd8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,107 @@ +## master + +## 1.4.8 (2018-03-22) + + * Now we return null for missing properties, like we do for missing keys for arrays. + +## 1.4.7 (2018-02-09) + + * Paginate tag shall now respect request parameters. + * It is now possible to set a custom query param for the paginate tag. + * Page number will now never go overboard. + +## 1.4.6 (2018-02-07) + + * TagPaginate shall not pollute the global scope, but work in own scope. + * TagPaginate errors if no collection present instead of vague warning. + +## 1.4.5 (2017-12-12) + + * Capture tag shall save a variable in the global context. + +## 1.4.4 (2017-11-03) + + * TagUnless is an inverted TagIf: simplified implementation + * Allow dashes in filenames + +## 1.4.3 (2017-10-10) + + * `escape` and `escape_once` filters now escape everything, but arrays + * New standard filter for explicit string conversion + +## 1.4.2 (2017-10-09) + + * Better caching for non-extending templates + * Simplified 'assign' tag to use rules for variables + * Now supporting PHP 7.2 + * Different types of exception depending on the case + * Filterbank will not call instance methods statically + * Callback-type filters + +## 1.4.1 (2017-09-28) + + * Unquoted template names in 'include' tag, as in Jekyll + * Caching now works correctly with 'extends' tag + +## 1.4.0 (2017-09-25) + + * Dropped support for EOL'ed versions of PHP (< 5.6) + * Arrays won't be silently cast to string as 'Array' anymore + * Complex objects could now be passed between templates and to filters + * Additional test coverage + +## 1.3.1 (2017-09-23) + + * Support for numeric and variable array indicies + * Support loop break and continue + * Allow looping over extended ranges + * Math filters now work with floats + * Fixed 'default' filter + * Local cache with data stored in a private variable + * Virtual file system to get inversion of control and DI + * Lots of tests with the coverage upped to 97% + * Small bug fixes and various enhancements + +## 1.3.0 (2017-07-17) + + * Support Traversable loops and filters + * Fix date filter for format with colon + * Various minor improvements and bugs fixes + +## 1.2.1 (2016-12-12) + + * Remove content injection from $_GET. + * Add PHP 5.6, 7.0, 7.1 to Travis file. + +## 1.2 (2016-06-11) + + * Added "ESCAPE_BY_DEFAULT" setting for context-aware auto-escaping. + * Made "Context" work with plain objects. + * "escape" now uses "htmlentities". + * Fixed "escape_now". + +## 1.1 (2015-06-01) + + * New tags: "paginate", "unless", "ifchanged" were added + * Added support for "for in (range)" syntax + * Added support for multiple conditions in if statements + * Added support for hashes/objects in for loops + +## 1.0 (2014-09-07) + + * Add namespaces + * Add composer support + * Implement new standard filters + * Add 'raw' tag + +## 0.9.2 (2012-08-15) + + * context->set allows now global vars + * Allow Templatenames with Fileextension + * Tag 'extends' supports now multiple inheritance + * Clean up code, change all variables and methods to camelCase + +## 0.9.1 (2012-05-12) + + * added the extends and block filter + * Initial release diff --git a/README.md b/README.md index fa2dc07b..9f7148aa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Liquid template engine for PHP [![Build Status](https://travis-ci.org/kalimatas/php-liquid.svg?branch=master)](https://travis-ci.org/kalimatas/php-liquid) +# Liquid template engine for PHP [![CI](https://github.com/kalimatas/php-liquid/actions/workflows/tests.yaml/badge.svg)](https://github.com/kalimatas/php-liquid/actions/workflows/tests.yaml) [![Coverage Status](https://coveralls.io/repos/github/kalimatas/php-liquid/badge.svg?branch=master)](https://coveralls.io/github/kalimatas/php-liquid?branch=master) [![Total Downloads](https://poser.pugx.org/liquid/liquid/downloads.svg)](https://packagist.org/packages/liquid/liquid) Liquid is a PHP port of the [Liquid template engine for Ruby](https://github.com/Shopify/liquid), which was written by Tobias Lutke. Although there are many other templating engines for PHP, including Smarty (from which Liquid was partially inspired), Liquid had some advantages that made porting worthwhile: @@ -21,7 +21,7 @@ Liquid was written to meet three templating library requirements: good performan You can install this lib via [composer](https://getcomposer.org/): - composer create-project liquid/liquid + composer require liquid/liquid ## Example template @@ -49,16 +49,75 @@ The main class is `Liquid::Template` class. There are two separate stages of wor $template = new Template(); $template->parse("Hello, {{ name }}!"); - echo $template->render(array('name' => 'Alex'); + echo $template->render(array('name' => 'Alex')); // Will echo // Hello, Alex! To find more examples have a look at the `examples` directory or at the original Ruby implementation repository's [wiki page](https://github.com/Shopify/liquid/wiki). +## Advanced usage + +You would probably want to add a caching layer (at very least a request-wide one), enable context-aware automatic escaping, and do load includes from disk with full file names. + + use Liquid\Liquid; + use Liquid\Template; + use Liquid\Cache\Local; + + Liquid::set('INCLUDE_SUFFIX', ''); + Liquid::set('INCLUDE_PREFIX', ''); + Liquid::set('INCLUDE_ALLOW_EXT', true); + Liquid::set('ESCAPE_BY_DEFAULT', true); + + $template = new Template(__DIR__.'/protected/templates/'); + + $template->parse("Hello, {% include 'honorific.html' %}{{ plain-html | raw }} {{ comment-with-xss }}"); + $template->setCache(new Local()); + + echo $template->render([ + 'name' => 'Alex', + 'plain-html' => 'Your comment was:', + 'comment-with-xss' => '', + ]); + +Will output: + + Hello, Mx. Alex + Your comment was: <script>alert();</script> + +Note that automatic escaping is not a standard Liquid feature: use with care. + +Similarly, the following snippet will parse and render `templates/home.liquid` while storing parsing results in a class-local cache: + + \Liquid\Liquid::set('INCLUDE_PREFIX', ''); + + $template = new \Liquid\Template(__DIR__ . '/protected/templates'); + $template->setCache(new \Liquid\Cache\Local()); + echo $template->parseFile('home')->render(); + +If you render the same template over and over for at least a dozen of times, the class-local cache will give you a slight speed up in range of some milliseconds per render depending on a complexity of your template. + +You should probably extend `Liquid\Template` to initialize everything you do with `Liquid::set` in one place. + +### Custom filters + +Adding filters has never been easier. + + $template = new Template(); + $template->registerFilter('absolute_url', function ($arg) { + return "https://www.example.com$arg"; + }); + $template->parse("{{ my_url | absolute_url }}"); + echo $template->render(array( + 'my_url' => '/test' + )); + // expect: https://www.example.com/test + ## Requirements - * PHP 5.3+ + * PHP 8.2+ + +Some earlier versions could be used with PHP 5.x/7.x, though they're not supported anymore. ## Issues diff --git a/composer.json b/composer.json index 67b2b241..10ce7ee1 100644 --- a/composer.json +++ b/composer.json @@ -1,32 +1,58 @@ { "name": "liquid/liquid", + "type": "library", "description": "Liquid template engine for PHP", - "keywords": ["liquid", "template"], - "homepage": "https://github.com/kalimatas/php-liquid", + "keywords": [ + "liquid", + "template" + ], + "homepage": "https://github.com/kalimatas/php-liquid", "license": "MIT", - "type": "library", "authors": [ { "name": "Guz Alexander", "email": "kalimatas@gmail.com", - "homepage": "http://guzalexander.com" + "homepage": "http://guzalexander.com" + }, + { + "name": "Harald Hanek" }, - { - "name": "Harald Hanek" - }, - { - "name": "Mateo Murphy" - } + { + "name": "Mateo Murphy" + }, + { + "name": "Alexey Kopytko", + "email": "alexey@kopytko.com", + "homepage": "https://www.alexeykopytko.com/" + } ], "require": { - "php": ">= 5.3" - }, - "require-dev": { - "phpunit/phpunit": "*" - }, - "autoload": { - "psr-4": { - "Liquid\\": "src/Liquid" - } - } + "php": "^8.2" + }, + "require-dev": { + "ergebnis/composer-normalize": ">=2.47", + "friendsofphp/php-cs-fixer": "^3.75", + "infection/infection": ">=0.17.6", + "php-coveralls/php-coveralls": "^2.8", + "phpunit/phpunit": "^9.6.23" + }, + "config": { + "sort-packages": true, + "allow-plugins": true + }, + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Liquid\\": "src/Liquid" + } + }, + "autoload-dev": { + "psr-4": { + "Liquid\\": "tests/Liquid" + } + } } diff --git a/examples/advanced.php b/examples/advanced.php new file mode 100644 index 00000000..5b89f3b5 --- /dev/null +++ b/examples/advanced.php @@ -0,0 +1,32 @@ +parse("Hello, {% include 'honorific.html' %}{{ plain-html | raw }} {{ comment-with-xss }}\n"); +$template->setCache(new Local()); + +echo $template->render([ + 'name' => 'Alex', + 'plain-html' => 'Your comment was:', + 'comment-with-xss' => '', +]); diff --git a/examples/block.php b/examples/block.php index 3fe9d884..dfb11286 100644 --- a/examples/block.php +++ b/examples/block.php @@ -1,6 +1,6 @@ addPsr4('Liquid\\', __DIR__ . '/../src/Liquid'); +require __DIR__ . '/../vendor/autoload.php'; use Liquid\Liquid; use Liquid\Template; @@ -29,12 +28,12 @@ $liquid->parse(file_get_contents($protectedPath . 'templates' . DIRECTORY_SEPARATOR . 'child.tpl')); -$assigns = array( - 'document' => array( +$assigns = [ + 'document' => [ 'title' => 'This is php-liquid', 'content' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.', - 'copyright' => '© Copyright 2014 Guz Alexander - All rights reserved.' - ) -); - + 'copyright' => '© Copyright 2014 Guz Alexander - All rights reserved.', + ], +]; + echo $liquid->render($assigns); diff --git a/examples/filters.php b/examples/filters.php new file mode 100644 index 00000000..0aa2872b --- /dev/null +++ b/examples/filters.php @@ -0,0 +1,25 @@ +registerFilter('absolute_url', function ($arg) { + return "https://www.example.com$arg"; +}); +$template->parse("{{ my_url | absolute_url }}"); +echo $template->render([ + 'my_url' => '/test', +]); +// expect: https://www.example.com/test diff --git a/examples/index.php b/examples/index.php index cdf707c4..2cd14b3a 100644 --- a/examples/index.php +++ b/examples/index.php @@ -1,6 +1,6 @@ addPsr4('Liquid\\', __DIR__ . '/../src/Liquid'); +require __DIR__ . '/../vendor/autoload.php'; use Liquid\Liquid; use Liquid\Template; @@ -30,42 +29,42 @@ $liquid->parse(file_get_contents($protectedPath . 'templates' . DIRECTORY_SEPARATOR . 'index.tpl')); -$assigns = array( - 'document' => array( +$assigns = [ + 'document' => [ 'title' => 'This is php-liquid', 'content' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.', - 'copyright' => 'Guz Alexander - All rights reserved.' - ), - 'blog' => array( - array( + 'copyright' => 'Guz Alexander - All rights reserved.', + ], + 'blog' => [ + [ 'title' => 'Blog Title 1', 'content' => 'Nunc putamus parum claram', - 'tags' => array('claram', 'parum'), - 'comments' => array( - array( + 'tags' => ['claram', 'parum'], + 'comments' => [ + [ 'title' => 'First Comment', - 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr' - ) - ) - ), - array( + 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', + ], + ], + ], + [ 'title' => 'Blog Title 2', 'content' => 'Nunc putamus parum claram', - 'tags' => array('claram', 'parum', 'freestyle'), - 'comments' => array( - array( + 'tags' => ['claram', 'parum', 'freestyle'], + 'comments' => [ + [ 'title' => 'First Comment', - 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr' - ), - array( + 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', + ], + [ 'title' => 'Second Comment', - 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr' - ) - ) - ) + 'message' => 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', + ], + ], + ], - ), - 'array' => array('one', 'two', 'three', 'four') -); + ], + 'array' => ['one', 'two', 'three', 'four'], +]; echo $liquid->render($assigns); diff --git a/examples/protected/templates/honorific.html b/examples/protected/templates/honorific.html new file mode 100644 index 00000000..a935dd27 --- /dev/null +++ b/examples/protected/templates/honorific.html @@ -0,0 +1 @@ +Mx. {{ name }} diff --git a/examples/simple.php b/examples/simple.php index d14a69ae..4381a080 100644 --- a/examples/simple.php +++ b/examples/simple.php @@ -1,6 +1,6 @@ addPsr4('Liquid\\', __DIR__ . '/../src/Liquid'); +require __DIR__ . '/../vendor/autoload.php'; use Liquid\Liquid; use Liquid\Template; @@ -21,4 +20,4 @@ $liquid = new Template(); $liquid->parse('{{ hello }} {{ goback }}'); -echo $liquid->render(array('hello' => 'hello world', 'goback' => 'index')); +echo $liquid->render(['hello' => 'hello world', 'goback' => 'index']); diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 00000000..51839eff --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,8 @@ +{ + "timeout": 2, + "source": { + "directories": [ + "src" + ] + } +} diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index dffe2f14..00000000 --- a/phpunit.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - tests/Liquid/ - - - - - src/Liquid/ - - - diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..29b38ac0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + + tests/ + + + + + + src/ + + + + + + + diff --git a/src/Liquid/AbstractBlock.php b/src/Liquid/AbstractBlock.php index e8d16dcf..45afee0b 100644 --- a/src/Liquid/AbstractBlock.php +++ b/src/Liquid/AbstractBlock.php @@ -1,6 +1,6 @@ nodelist; } @@ -36,23 +58,30 @@ public function getNodelist() { * @throws \Liquid\LiquidException * @return void */ - public function parse(array &$tokens) { - $startRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '/'); - $tagRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*(\w+)\s*(.*)?' . Liquid::get('TAG_END') . '$/'); - $variableStartRegexp = new Regexp('/^' . Liquid::get('VARIABLE_START') . '/'); + public function parse(array &$tokens) + { + // Constructor is not reliably called by subclasses, so we need to ensure these are set + $this->startRegexp ??= new Regexp('/^' . Liquid::get('TAG_START') . '/'); + $this->tagRegexp ??= new Regexp('/^' . Liquid::get('TAG_START') . Liquid::get('WHITESPACE_CONTROL') . '?\s*(\w+)\s*(.*?)' . Liquid::get('WHITESPACE_CONTROL') . '?' . Liquid::get('TAG_END') . '$/s'); + $this->variableStartRegexp ??= new Regexp('/^' . Liquid::get('VARIABLE_START') . '/'); - $this->nodelist = array(); + $startRegexp = $this->startRegexp; + $tagRegexp = $this->tagRegexp; + $variableStartRegexp = $this->variableStartRegexp; - if (!is_array($tokens)) { - return; - } + $this->nodelist = []; $tags = Template::getTags(); - while (count($tokens)) { - $token = array_shift($tokens); + for ($i = 0, $n = count($tokens); $i < $n; $i++) { + if ($tokens[$i] === null) { + continue; + } + $token = $tokens[$i]; + $tokens[$i] = null; if ($startRegexp->match($token)) { + $this->whitespaceHandler($token); if ($tagRegexp->match($token)) { // If we found the proper block delimitor just end parsing here and let the outer block proceed if ($tagRegexp->matches[1] == $this->blockDelimiter()) { @@ -64,7 +93,7 @@ public function parse(array &$tokens) { if (array_key_exists($tagRegexp->matches[1], $tags)) { $tagName = $tags[$tagRegexp->matches[1]]; } else { - $tagName = '\Liquid\Tag\Tag' . ucwords($tagRegexp->matches[1]); + $tagName = self::TAG_PREFIX . ucwords($tagRegexp->matches[1]); $tagName = (class_exists($tagName) === true) ? $tagName : null; } @@ -77,13 +106,18 @@ public function parse(array &$tokens) { $this->unknownTag($tagRegexp->matches[1], $tagRegexp->matches[2], $tokens); } } else { - throw new LiquidException("Tag $token was not properly terminated"); // harry + throw new ParseException("Tag $token was not properly terminated (won't match $tagRegexp)"); } - } elseif ($variableStartRegexp->match($token)) { + $this->whitespaceHandler($token); $this->nodelist[] = $this->createVariable($token); + } else { + // This is neither a tag or a variable, proceed with an ltrim + if (self::$trimWhitespace) { + $token = ltrim($token); + } - } elseif ($token != '') { + self::$trimWhitespace = false; $this->nodelist[] = $token; } } @@ -91,6 +125,33 @@ public function parse(array &$tokens) { $this->assertMissingDelimitation(); } + /** + * Handle the whitespace. + * + * @param string $token + */ + protected function whitespaceHandler($token) + { + $this->whitespaceControl ??= Liquid::get('WHITESPACE_CONTROL'); + + /* + * This assumes that TAG_START is always '{%', and a whitespace control indicator + * is exactly one character long, on a third position. + */ + if ($token[2] === $this->whitespaceControl) { + $previousToken = end($this->nodelist); + if (is_string($previousToken)) { // this can also be a tag or a variable + $this->nodelist[key($this->nodelist)] = rtrim($previousToken); + } + } + + /* + * This assumes that TAG_END is always '%}', and a whitespace control indicator + * is exactly one character long, on a third position from the end. + */ + self::$trimWhitespace = $token[-3] === $this->whitespaceControl; + } + /** * Render the block. * @@ -98,7 +159,8 @@ public function parse(array &$tokens) { * * @return string */ - public function render(Context $context) { + public function render(Context $context) + { return $this->renderAll($this->nodelist, $context); } @@ -110,11 +172,31 @@ public function render(Context $context) { * * @return string */ - protected function renderAll(array $list, Context $context) { + protected function renderAll(array $list, Context $context) + { $result = ''; foreach ($list as $token) { - $result .= (is_object($token) && method_exists($token, 'render')) ? $token->render($context) : $token; + if (is_object($token) && method_exists($token, 'render')) { + $value = $token->render($context); + } else { + $value = $token; + } + + if (is_array($value)) { + $value = htmlspecialchars(implode($value)); + } + + $result .= $value; + + if (isset($context->registers['break'])) { + break; + } + if (isset($context->registers['continue'])) { + break; + } + + $context->tick(); } return $result; @@ -123,7 +205,8 @@ protected function renderAll(array $list, Context $context) { /** * An action to execute when the end tag is reached */ - protected function endTag() { + protected function endTag() + { // Do nothing by default } @@ -134,28 +217,30 @@ protected function endTag() { * @param string $params * @param array $tokens * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\ParseException */ - protected function unknownTag($tag, $params, array $tokens) { + protected function unknownTag($tag, $params, array $tokens) + { switch ($tag) { case 'else': - throw new LiquidException($this->blockName() . " does not expect else tag"); + throw new ParseException($this->blockName() . " does not expect else tag"); case 'end': - throw new LiquidException("'end' is not a valid delimiter for " . $this->blockName() . " tags. Use " . $this->blockDelimiter()); + throw new ParseException("'end' is not a valid delimiter for " . $this->blockName() . " tags. Use " . $this->blockDelimiter()); default: - throw new LiquidException("Unkown tag $tag"); + throw new ParseException("Unknown tag $tag"); } } /** - * This method is called at the end of parsing, and will through an error unless + * This method is called at the end of parsing, and will throw an error unless * this method is subclassed, like it is for Document * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\ParseException * @return bool */ - protected function assertMissingDelimitation() { - throw new LiquidException($this->blockName() . " tag was never closed"); + protected function assertMissingDelimitation() + { + throw new ParseException($this->blockName() . " tag was never closed"); } /** @@ -163,7 +248,8 @@ protected function assertMissingDelimitation() { * * @return string */ - protected function blockDelimiter() { + protected function blockDelimiter() + { return "end" . $this->blockName(); } @@ -172,7 +258,8 @@ protected function blockDelimiter() { * * @return string */ - private function blockName() { + private function blockName() + { $reflection = new \ReflectionClass($this); return str_replace('tag', '', strtolower($reflection->getShortName())); } @@ -182,15 +269,17 @@ private function blockName() { * * @param string $token * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\ParseException * @return Variable */ - private function createVariable($token) { - $variableRegexp = new Regexp('/^' . Liquid::get('VARIABLE_START') . '(.*)' . Liquid::get('VARIABLE_END') . '$/'); - if ($variableRegexp->match($token)) { - return new Variable($variableRegexp->matches[1]); + private function createVariable($token) + { + $this->variableRegexp ??= new Regexp('/^' . Liquid::get('VARIABLE_START') . Liquid::get('WHITESPACE_CONTROL') . '?(.*?)' . Liquid::get('WHITESPACE_CONTROL') . '?' . Liquid::get('VARIABLE_END') . '$/s'); + + if ($this->variableRegexp->match($token)) { + return new Variable($this->variableRegexp->matches[1]); } - throw new LiquidException("Variable $token was not properly terminated"); + throw new ParseException("Variable $token was not properly terminated"); } } diff --git a/src/Liquid/AbstractTag.php b/src/Liquid/AbstractTag.php index bd70f71b..0b47418f 100644 --- a/src/Liquid/AbstractTag.php +++ b/src/Liquid/AbstractTag.php @@ -1,6 +1,6 @@ markup = $markup; $this->fileSystem = $fileSystem; + $this->config = &Liquid::$config; + $this->parse($tokens); } @@ -55,7 +65,8 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @param array $tokens */ - public function parse(array &$tokens) { + public function parse(array &$tokens) + { // Do nothing by default } @@ -66,17 +77,16 @@ public function parse(array &$tokens) { * * @return string */ - public function render(Context $context) { - return ''; - } + abstract public function render(Context $context); /** * Extracts tag attributes from a markup string. * * @param string $markup */ - protected function extractAttributes($markup) { - $this->attributes = array(); + protected function extractAttributes($markup) + { + $this->attributes = []; $attributeRegexp = new Regexp(Liquid::get('TAG_ATTRIBUTES')); @@ -92,7 +102,8 @@ protected function extractAttributes($markup) { * * @return string */ - protected function name() { + protected function name() + { return strtolower(get_class($this)); } } diff --git a/src/Liquid/Cache.php b/src/Liquid/Cache.php index 83b5bdee..3bfb6ac7 100644 --- a/src/Liquid/Cache.php +++ b/src/Liquid/Cache.php @@ -1,6 +1,6 @@ expire = $options['cache_expire']; } diff --git a/src/Liquid/Cache/Apc.php b/src/Liquid/Cache/Apc.php index eb6e6642..0256ad66 100644 --- a/src/Liquid/Cache/Apc.php +++ b/src/Liquid/Cache/Apc.php @@ -1,6 +1,6 @@ prefix . $key); } /** * {@inheritdoc} */ - public function exists($key) { + public function exists($key) + { apc_fetch($this->prefix . $key, $success); - return $success; + return (bool) $success; } /** * {@inheritdoc} */ - public function write($key, $value, $serialize = true) { + public function write($key, $value, $serialize = true) + { return apc_store($this->prefix . $key, $value, $this->expire); } /** * {@inheritdoc} */ - public function flush($expiredOnly = false) { + public function flush($expiredOnly = false) + { return apc_clear_cache('user'); } } diff --git a/src/Liquid/Cache/File.php b/src/Liquid/Cache/File.php index 34fe72d3..1ece9e3d 100644 --- a/src/Liquid/Cache/File.php +++ b/src/Liquid/Cache/File.php @@ -1,6 +1,6 @@ path = realpath($options['cache_dir']) . DIRECTORY_SEPARATOR; } else { - throw new LiquidException('Cachedir not exists or not writable'); + throw new NotFoundException('Cachedir not exists or not writable'); } } /** * {@inheritdoc} */ - public function read($key, $unserialize = true) { - if (!$this->exists($key)) + public function read($key, $unserialize = true) + { + if (!$this->exists($key)) { return false; + } if ($unserialize) { return unserialize(file_get_contents($this->path . $this->prefix . $key)); @@ -55,7 +58,8 @@ public function read($key, $unserialize = true) { /** * {@inheritdoc} */ - public function exists($key) { + public function exists($key) + { $cacheFile = $this->path . $this->prefix . $key; if (!file_exists($cacheFile) || filemtime($cacheFile) + $this->expire < time()) { @@ -68,19 +72,19 @@ public function exists($key) { /** * {@inheritdoc} */ - public function write($key, $value, $serialize = true) { - if (file_put_contents($this->path . $this->prefix . $key, $serialize ? serialize($value) : $value) !== false) { - $this->gc(); - return true; - } + public function write($key, $value, $serialize = true) + { + $bytes = file_put_contents($this->path . $this->prefix . $key, $serialize ? serialize($value) : $value); + $this->gc(); - throw new LiquidException('Can not write cache file'); + return $bytes !== false; } /** * {@inheritdoc} */ - public function flush($expiredOnly = false) { + public function flush($expiredOnly = false) + { foreach (glob($this->path . $this->prefix . '*') as $file) { if ($expiredOnly) { if (filemtime($file) + $this->expire < time()) { @@ -95,7 +99,8 @@ public function flush($expiredOnly = false) { /** * {@inheritdoc} */ - protected function gc() { + protected function gc() + { $this->flush(true); } } diff --git a/src/Liquid/Cache/Local.php b/src/Liquid/Cache/Local.php new file mode 100644 index 00000000..933da518 --- /dev/null +++ b/src/Liquid/Cache/Local.php @@ -0,0 +1,60 @@ +cache[$key])) { + return $this->cache[$key]; + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function exists($key) + { + return isset($this->cache[$key]); + } + + /** + * {@inheritdoc} + */ + public function write($key, $value, $serialize = true) + { + $this->cache[$key] = $value; + return true; + } + + /** + * {@inheritdoc} + */ + public function flush($expiredOnly = false) + { + $this->cache = []; + return true; + } +} diff --git a/src/Liquid/Context.php b/src/Liquid/Context.php index 9a1ca3e9..066427b7 100644 --- a/src/Liquid/Context.php +++ b/src/Liquid/Context.php @@ -1,6 +1,6 @@ assigns = array($assigns); + public function __construct(array $assigns = [], array $registers = []) + { + $this->assigns = [$assigns]; $this->registers = $registers; $this->filterbank = new Filterbank($this); - // first empty array serves as source for ovverides, e.g. as in TagDecrement - $this->environments = array(array(), $_SERVER); + + // first empty array serves as source for overrides, e.g. as in TagDecrement + $this->environments = [[], []]; + + if (Liquid::get('EXPOSE_SERVER')) { + $this->environments[1] = $_SERVER; + } else { + $this->environments[1] = array_filter( + $_SERVER, + function ($key) { + return in_array( + $key, + (array)Liquid::get('SERVER_SUPERGLOBAL_WHITELIST') + ); + }, + ARRAY_FILTER_USE_KEY + ); + } + } + + /** + * Sets a tick function, this function is called sometimes while liquid is rendering a template. + * + * @param callable $tickFunction + */ + public function setTickFunction(callable $tickFunction) + { + $this->tickFunction = $tickFunction; } /** @@ -63,8 +97,9 @@ public function __construct(array $assigns = array(), array $registers = array() * * @param mixed $filter */ - public function addFilters($filter) { - $this->filterbank->addFilter($filter); + public function addFilters($filter, ?callable $callback = null) + { + $this->filterbank->addFilter($filter, $callback); } /** @@ -76,8 +111,13 @@ public function addFilters($filter) { * * @return string */ - public function invoke($name, $value, array $args = array()) { - return $this->filterbank->invoke($name, $value, $args); + public function invoke($name, $value, array $args = []) + { + try { + return $this->filterbank->invoke($name, $value, $args); + } catch (\TypeError $typeError) { + throw new LiquidException($typeError->getMessage(), 0, $typeError); + } } /** @@ -85,7 +125,8 @@ public function invoke($name, $value, array $args = array()) { * * @param array $newAssigns */ - public function merge($newAssigns) { + public function merge($newAssigns) + { $this->assigns[0] = array_merge($this->assigns[0], $newAssigns); } @@ -94,8 +135,9 @@ public function merge($newAssigns) { * * @return bool */ - public function push() { - array_unshift($this->assigns, array()); + public function push() + { + array_unshift($this->assigns, []); return true; } @@ -105,7 +147,8 @@ public function push() { * @throws LiquidException * @return bool */ - public function pop() { + public function pop() + { if (count($this->assigns) == 1) { throw new LiquidException('No elements to pop'); } @@ -117,10 +160,12 @@ public function pop() { * Replaces [] * * @param string + * @param mixed $key * * @return mixed */ - public function get($key) { + public function get($key) + { return $this->resolve($key); } @@ -131,7 +176,8 @@ public function get($key) { * @param mixed $value * @param bool $global */ - public function set($key, $value, $global = false) { + public function set($key, $value, $global = false) + { if ($global) { for ($i = 0; $i < count($this->assigns); $i++) { $this->assigns[$i][$key] = $value; @@ -148,7 +194,8 @@ public function set($key, $value, $global = false) { * * @return bool */ - public function hasKey($key) { + public function hasKey($key) + { return (!is_null($this->resolve($key))); } @@ -162,7 +209,8 @@ public function hasKey($key) { * @throws LiquidException * @return mixed */ - private function resolve($key) { + private function resolve($key) + { // This shouldn't happen if (is_array($key)) { throw new LiquidException("Cannot resolve arrays as key"); @@ -188,11 +236,11 @@ private function resolve($key) { return $matches[1]; } - if (preg_match('/^(\d+)$/', $key, $matches)) { + if (preg_match('/^(-?\d+)$/', $key, $matches)) { return $matches[1]; } - if (preg_match('/^(\d[\d\.]+)$/', $key, $matches)) { + if (preg_match('/^(-?\d[\d\.]+)$/', $key, $matches)) { return $matches[1]; } @@ -206,7 +254,8 @@ private function resolve($key) { * * @return mixed */ - private function fetch($key) { + private function fetch($key) + { // TagDecrement depends on environments being checked before assigns foreach ($this->environments as $environment) { if (array_key_exists($key, $environment)) { @@ -234,94 +283,176 @@ private function fetch($key) { * * @param string $key * + * @see Decision::stringValue + * @see AbstractBlock::renderAll + * * @throws LiquidException * @return mixed */ - private function variable($key) { - // Support [0] style array indicies + private function variable($key) + { + // Support numeric and variable array indicies if (preg_match("|\[[0-9]+\]|", $key)) { $key = preg_replace("|\[([0-9]+)\]|", ".$1", $key); + } elseif (preg_match("|\[[0-9a-z._]+\]|", $key, $matches)) { + $index = $this->get(str_replace(["[", "]"], "", $matches[0])); + if (strlen($index)) { + $key = preg_replace("|\[([0-9a-z._]+)\]|", ".$index", $key); + } } $parts = explode(Liquid::get('VARIABLE_ATTRIBUTE_SEPARATOR'), $key); $object = $this->fetch(array_shift($parts)); - if (is_object($object)) { - if (method_exists($object, 'toLiquid')) { - $object = $object->toLiquid(); - } else if (method_exists($object, 'toArray')) { - $object = $object->toArray(); + while (count($parts) > 0) { + // since we still have a part to consider + // and since we can't dig deeper into plain values + // it can be thought as if it has a property with a null value + if (!is_object($object) && !is_array($object) && !is_string($object)) { + return null; } - // we'll cover regular objects later - } - if ($object === null) { - return null; - } + // first try to cast an object to an array or value + if (is_object($object)) { + if (method_exists($object, 'toLiquid')) { + $object = $object->toLiquid(); + } elseif (method_exists($object, 'toArray')) { + $object = $object->toArray(); + } + } + + if (is_null($object)) { + return null; + } - while (count($parts) > 0) { if ($object instanceof Drop) { $object->setContext($this); } $nextPartName = array_shift($parts); + if (is_string($object)) { + if ($nextPartName == 'size') { + // if the last part of the context variable is .size we return the string length + return mb_strlen($object); + } + + // no other special properties for strings, yet + return null; + } + if (is_array($object)) { + // if the last part of the context variable is .first we return the first array element + if ($nextPartName == 'first' && count($parts) == 0 && !array_key_exists('first', $object)) { + return StandardFilters::first($object); + } + + // if the last part of the context variable is .last we return the last array element + if ($nextPartName == 'last' && count($parts) == 0 && !array_key_exists('last', $object)) { + return StandardFilters::last($object); + } + // if the last part of the context variable is .size we just return the count if ($nextPartName == 'size' && count($parts) == 0 && !array_key_exists('size', $object)) { return count($object); } - if (array_key_exists($nextPartName, $object)) { - $object = $object[$nextPartName]; - } else { + // no key - no value + if (!array_key_exists($nextPartName, $object)) { return null; } - } elseif (is_object($object)) { - if ($object instanceof Drop) { - // if the object is a drop, make sure it supports the given method - if (!$object->hasKey($nextPartName)) { - return null; - } - - $object = $object->invokeDrop($nextPartName); - } elseif (method_exists($object, Liquid::get('HAS_PROPERTY_METHOD'))) { - if (!call_user_func(array($object, Liquid::get('HAS_PROPERTY_METHOD')), $nextPartName)) { - return null; - } - - call_user_func(array($object, Liquid::get('GET_PROPERTY_METHOD')), $nextPartName); - } else { - // if it's just a regular object, attempt to access a property - if (property_exists($object, $nextPartName)) { - $object = $object->$nextPartName; - } elseif (method_exists($object, $nextPartName)) { - // then try a method - $object = call_user_func(array($object, $nextPartName)); - } else { - return null; - } + $object = $object[$nextPartName]; + continue; + } + + if (!is_object($object)) { + // we got plain value, yet asked to resolve a part + // think plain values have a null part with any name + return null; + } + + if ($object instanceof \Countable) { + // if the last part of the context variable is .size we just return the count + if ($nextPartName == 'size' && count($parts) == 0) { + return count($object); + } + } + + if ($object instanceof Drop) { + // if the object is a drop, make sure it supports the given method + if (!$object->hasKey($nextPartName)) { + return null; } + + $object = $object->invokeDrop($nextPartName); + continue; + } + + // if it has `get` or `field_exists` methods + if (method_exists($object, Liquid::get('HAS_PROPERTY_METHOD'))) { + if (!call_user_func([$object, Liquid::get('HAS_PROPERTY_METHOD')], $nextPartName)) { + return null; + } + + $object = call_user_func([$object, Liquid::get('GET_PROPERTY_METHOD')], $nextPartName); + continue; + } + + // if it's just a regular object, attempt to access a public method + if (is_callable([$object, $nextPartName])) { + $object = call_user_func([$object, $nextPartName]); + continue; + } + + // if a magic accessor method present... + if (is_object($object) && method_exists($object, '__get')) { + $object = $object->$nextPartName; + continue; } - } - // finally, resolve objects to values - if (is_object($object)) { - if (method_exists($object, '__toString')) { - $object = (string) $object; - } elseif (method_exists($object, 'toLiquid')) { - $object = $object->toLiquid(); + // Inexistent property is a null, PHP-speak + if (!property_exists($object, $nextPartName)) { + return null; } + + // then try a property (independent of accessibility) + if (property_exists($object, $nextPartName)) { + $object = $object->$nextPartName; + continue; + } + + // we'll try casting this object in the next iteration } - // if everything else fails, throw up - if (is_object($object)) { - $class = get_class($object); - throw new LiquidException("Value of type $class has no `toLiquid` nor `__toString` method"); + // lastly, try to get an embedded value of an object + // value could be of any type, not just string, so we have to do this + // conversion here, not later in AbstractBlock::renderAll + if (is_object($object) && method_exists($object, 'toLiquid')) { + $object = $object->toLiquid(); } + /* + * Before here were checks for object types and object to string conversion. + * + * Now we just return what we have: + * - Traversable objects are taken care of inside filters + * - Object-to-string conversion is handled at the last moment in Decision::stringValue, and in AbstractBlock::renderAll + * + * This way complex objects could be passed between templates and to filters + */ + return $object; } + + public function tick() + { + if ($this->tickFunction === null) { + return; + } + + $tickFunction = $this->tickFunction; + $tickFunction($this); + } } diff --git a/src/Liquid/CustomFilters.php b/src/Liquid/CustomFilters.php index e2d3dd1e..ce028fcf 100644 --- a/src/Liquid/CustomFilters.php +++ b/src/Liquid/CustomFilters.php @@ -1,6 +1,6 @@ valid(); } + + // toLiquid is handled in Context::variable + $class = get_class($value); + throw new RenderException("Value of type $class has no `toLiquid` nor `__toString` methods"); } // Arrays simply return true @@ -65,7 +74,8 @@ private function stringValue($value) { * * @return bool */ - protected function equalVariables($left, $right, Context $context) { + protected function equalVariables($left, $right, Context $context) + { $left = $this->stringValue($context->get($left)); $right = $this->stringValue($context->get($right)); @@ -80,10 +90,11 @@ protected function equalVariables($left, $right, Context $context) { * @param string $op * @param Context $context * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\RenderException * @return bool */ - protected function interpretCondition($left, $right, $op = null, Context $context) { + protected function interpretCondition($left, $right, $op, Context $context) + { if (is_null($op)) { $value = $this->stringValue($context->get($left)); return $value; @@ -93,11 +104,9 @@ protected function interpretCondition($left, $right, $op = null, Context $contex if ($right == 'empty' && is_array($context->get($left))) { $left = count($context->get($left)); $right = 0; - } elseif ($left == 'empty' && is_array($context->get($right))) { $right = count($context->get($right)); $left = 0; - } else { $left = $context->get($left); $right = $context->get($right); @@ -146,7 +155,7 @@ protected function interpretCondition($left, $right, $op = null, Context $contex return is_array($left) ? in_array($right, $left) : (strpos($left, $right) !== false); default: - throw new LiquidException("Error in tag '" . $this->name() . "' - Unknown operator $op"); + throw new RenderException("Error in tag '" . $this->name() . "' - Unknown operator $op"); } } } diff --git a/src/Liquid/Document.php b/src/Liquid/Document.php index 5aacb0c7..166c81e0 100644 --- a/src/Liquid/Document.php +++ b/src/Liquid/Document.php @@ -1,6 +1,6 @@ fileSystem = $fileSystem; - $this->parse($tokens); + public function __construct(array &$tokens, ?FileSystem $fileSystem = null) + { + parent::__construct('', $tokens, $fileSystem); } /** - * Check for cached includes + * Check for cached includes; if there are - do not use cache * - * @return string + * @see \Liquid\Tag\TagInclude::hasIncludes() + * @see \Liquid\Tag\TagExtends::hasIncludes() + * @return bool if need to discard cache */ - public function checkIncludes() { + public function hasIncludes() + { + $seenExtends = false; + $seenBlock = false; + foreach ($this->nodelist as $token) { - if (is_object($token)) { - if ($token instanceof TagInclude || $token instanceof TagExtends) { - /** @var TagInclude|TagExtends $token */ - if ($token->checkIncludes() == true) { - return true; - } - } + if ($token instanceof TagExtends) { + $seenExtends = true; + } elseif ($token instanceof TagBlock) { + $seenBlock = true; + } + } + + /* + * We try to keep the base templates in cache (that not extend anything). + * + * At the same time if we re-render all other blocks we see, we avoid most + * if not all related caching quirks. This may be suboptimal. + */ + if ($seenBlock && !$seenExtends) { + return true; + } + + foreach ($this->nodelist as $token) { + // check any of the tokens for includes + if ($token instanceof TagInclude && $token->hasIncludes()) { + return true; + } + + if ($token instanceof TagExtends && $token->hasIncludes()) { + return true; } } @@ -55,13 +80,15 @@ public function checkIncludes() { * * @return string */ - protected function blockDelimiter() { + protected function blockDelimiter() + { return ''; } /** * Document blocks don't need to be terminated since they are not actually opened */ - protected function assertMissingDelimitation() { + protected function assertMissingDelimitation() + { } } diff --git a/src/Liquid/Drop.php b/src/Liquid/Drop.php index cc2ef511..48e71f5e 100644 --- a/src/Liquid/Drop.php +++ b/src/Liquid/Drop.php @@ -1,6 +1,6 @@ context = $context; } @@ -64,10 +66,11 @@ public function setContext(Context $context) { * * @return mixed */ - public function invokeDrop($method) { + public function invokeDrop($method) + { $result = $this->beforeMethod($method); - if (is_null($result) && is_callable(array($this, $method))) { + if (is_null($result) && is_callable([$this, $method])) { $result = $this->$method(); } @@ -81,21 +84,24 @@ public function invokeDrop($method) { * * @return bool */ - public function hasKey($name) { + public function hasKey($name) + { return true; } /** * @return Drop */ - public function toLiquid() { + public function toLiquid() + { return $this; } /** * @return string */ - public function __toString() { + public function __toString() + { return get_class($this); } } diff --git a/tests/bootstrap.php b/src/Liquid/Exception/CacheException.php similarity index 56% rename from tests/bootstrap.php rename to src/Liquid/Exception/CacheException.php index abb3ec3d..4ccb6b77 100644 --- a/tests/bootstrap.php +++ b/src/Liquid/Exception/CacheException.php @@ -1,6 +1,6 @@ addPsr4('Liquid\\', __DIR__ . '/../src/Liquid'); -$loader->addPsr4('Liquid\\', __DIR__ . '/Liquid'); +namespace Liquid\Exception; + +use Liquid\LiquidException; + +/** + * CacheException class. + */ +class CacheException extends LiquidException +{ +} diff --git a/src/Liquid/Exception/FilesystemException.php b/src/Liquid/Exception/FilesystemException.php new file mode 100644 index 00000000..a4aa3967 --- /dev/null +++ b/src/Liquid/Exception/FilesystemException.php @@ -0,0 +1,21 @@ +root = $root; + } + + /** + * Retrieve a template file + * + * @param string $templatePath + * + * @return string template content + */ + public function readTemplateFile($templatePath) + { + return file_get_contents($this->fullPath($templatePath)); + } + + /** + * Resolves a given path to a full template file path, making sure it's valid + * + * @param string $templatePath + * + * @throws \Liquid\Exception\ParseException + * @throws \Liquid\Exception\NotFoundException + * @return string + */ + public function fullPath($templatePath) + { + if (empty($templatePath)) { + throw new ParseException("Empty template name"); + } + + $nameRegex = Liquid::get('INCLUDE_ALLOW_EXT') + ? new Regexp('/^[^.\/][a-zA-Z0-9_\.\/-]+$/') + : new Regexp('/^[^.\/][a-zA-Z0-9_\/-]+$/'); + + if (!$nameRegex->match($templatePath)) { + throw new ParseException("Illegal template name '$templatePath'"); + } + + $templateDir = dirname($templatePath); + $templateFile = basename($templatePath); + + if (!Liquid::get('INCLUDE_ALLOW_EXT')) { + $templateFile = Liquid::get('INCLUDE_PREFIX') . $templateFile . '.' . Liquid::get('INCLUDE_SUFFIX'); + } + + $fullPath = join(DIRECTORY_SEPARATOR, [$this->root, $templateDir, $templateFile]); + + $realFullPath = realpath($fullPath); + if ($realFullPath === false) { + throw new NotFoundException("File not found: $fullPath"); + } + + if (strpos($realFullPath, $this->root) !== 0) { + throw new NotFoundException("Illegal template full path: {$realFullPath} not under {$this->root}"); + } + + return $realFullPath; + } +} diff --git a/src/Liquid/FileSystem/Virtual.php b/src/Liquid/FileSystem/Virtual.php new file mode 100644 index 00000000..3ceda2f1 --- /dev/null +++ b/src/Liquid/FileSystem/Virtual.php @@ -0,0 +1,64 @@ +callback = $callback; + } + + /** + * Retrieve a template file + * + * @param string $templatePath + * + * @return string template content + */ + public function readTemplateFile($templatePath) + { + return call_user_func($this->callback, $templatePath); + } + + public function __sleep() + { + // we cannot serialize a closure + if ($this->callback instanceof \Closure) { + throw new FilesystemException("Virtual file system with a Closure as a callback cannot be used with a serializing cache"); + } + + return array_keys(get_object_vars($this)); + } +} diff --git a/src/Liquid/Filterbank.php b/src/Liquid/Filterbank.php index f3943201..17c305d5 100644 --- a/src/Liquid/Filterbank.php +++ b/src/Liquid/Filterbank.php @@ -1,6 +1,6 @@ context = $context; - $this->addFilter('\Liquid\StandardFilters'); - $this->addFilter('\Liquid\CustomFilters'); + $this->addFilter(\Liquid\StandardFilters::class); + $this->addFilter(\Liquid\CustomFilters::class); } /** * Adds a filter to the bank * * @param mixed $filter Can either be an object, the name of a class (in which case the - * filters will be called statically) or the name of a function. + * filters will be called statically) or the name of a function. * - * @throws LiquidException + * @throws \Liquid\Exception\WrongArgumentException * @return bool */ - public function addFilter($filter) { - // If the passed filter was an object, store the object for future reference. - if (is_object($filter)) { - $filter->context = $this->context; - $name = get_class($filter); - $this->filters[$name] = $filter; - $filter = $name; - } - - // If it wasn't an object an isn't a string either, it's a bad parameter - if (!is_string($filter)) { - throw new LiquidException("Parameter passed to addFilter must be an object or a string"); + public function addFilter($filter, ?callable $callback = null) + { + // If it is a callback, save it as it is + if (is_string($filter) && $callback) { + $this->methodMap[$filter] = $callback; + return true; } - // If the filter is a class, register all its methods - if (class_exists($filter)) { - $methods = array_flip(get_class_methods($filter)); - foreach ($methods as $method => $null) { - $this->methodMap[$method] = $filter; + // If the filter is a class, register all its static methods + if (is_string($filter) && class_exists($filter)) { + $reflection = new \ReflectionClass($filter); + foreach ($reflection->getMethods(\ReflectionMethod::IS_STATIC) as $method) { + $this->methodMap[$method->name] = $method->class; } return true; } - // If it's a function register it simply - if (function_exists($filter)) { + // If it's a global function, register it simply + if (is_string($filter) && function_exists($filter)) { $this->methodMap[$filter] = false; return true; } - throw new LiquidException("Parameter passed to addFilter must a class or a function"); + // If it isn't an object an isn't a string either, it's a bad parameter + if (!is_object($filter)) { + throw new WrongArgumentException("Parameter passed to addFilter must be an object or a string"); + } + + // If the passed filter was an object, store the object for future reference. + $filter->context = $this->context; + $className = get_class($filter); + $this->filters[$className] = $filter; + + // Then register all public static and not methods as filters + foreach (get_class_methods($filter) as $method) { + if (strtolower($method) === '__construct') { + continue; + } + $this->methodMap[$method] = $className; + } + + return true; } /** @@ -101,26 +116,38 @@ public function addFilter($filter) { * * @return string */ - public function invoke($name, $value, array $args = array()) { + public function invoke($name, $value, array $args = []) + { + // workaround for a single standard filter being a reserved keyword - we can't use overloading for static calls + if ($name == 'default') { + $name = '_default'; + } + array_unshift($args, $value); // Consult the mapping - if (isset($this->methodMap[$name])) { - $class = $this->methodMap[$name]; + if (!isset($this->methodMap[$name])) { + return $value; + } - // If we have a registered object for the class, use that instead - if (isset($this->filters[$class])) { - $class = $this->filters[$class]; - } + $class = $this->methodMap[$name]; - // If we're calling a function - if ($class === false) { - return call_user_func_array($name, $args); - } else { - return call_user_func_array(array($class, $name), $args); - } + // If we have a callback + if (is_callable($class)) { + return call_user_func_array($class, $args); + } + + // If we have a registered object for the class, use that instead + if (isset($this->filters[$class])) { + $class = $this->filters[$class]; + } + + // If we're calling a function + if ($class === false) { + return call_user_func_array($name, $args); } - return $value; + // Call a class or an instance method + return call_user_func_array([$class, $name], $args); } } diff --git a/src/Liquid/Liquid.php b/src/Liquid/Liquid.php index c668886a..6a02d04e 100644 --- a/src/Liquid/Liquid.php +++ b/src/Liquid/Liquid.php @@ -1,6 +1,6 @@ 'field_exists', @@ -52,6 +52,9 @@ class Liquid // Prefix for include files. 'INCLUDE_PREFIX' => '_', + // Whitespace control. + 'WHITESPACE_CONTROL' => '-', + // Tag start. 'TAG_START' => '{%', @@ -64,15 +67,40 @@ class Liquid // Variable end. 'VARIABLE_END' => '}}', - // The characters allowed in a variable. - 'ALLOWED_VARIABLE_CHARS' => '[a-zA-Z_.-]', + // Variable name. + 'VARIABLE_NAME' => '[a-zA-Z_][a-zA-Z_0-9.-]*', - 'QUOTED_STRING' => '"[^"]*"|\'[^\']*\'', - 'QUOTED_STRING_FILTER_ARGUMENT' => '"[^":]*"|\'[^\':]*\'', + 'QUOTED_STRING' => '(?:"[^"]*"|\'[^\']*\')', + 'QUOTED_STRING_FILTER_ARGUMENT' => '"[^"]*"|\'[^\']*\'', // Automatically escape any variables unless told otherwise by a "raw" filter 'ESCAPE_BY_DEFAULT' => false, - ); + + // The name of the key to use when building pagination query strings e.g. ?page=1 + 'PAGINATION_REQUEST_KEY' => 'page', + + // The name of the context key used to denote the current page number + 'PAGINATION_CONTEXT_KEY' => 'page', + + // Whenever variables from $_SERVER should be directly available to templates + 'EXPOSE_SERVER' => false, + + // $_SERVER variables whitelist - exposed even when EXPOSE_SERVER is false + 'SERVER_SUPERGLOBAL_WHITELIST' => [ + 'HTTP_ACCEPT', + 'HTTP_ACCEPT_CHARSET', + 'HTTP_ACCEPT_ENCODING', + 'HTTP_ACCEPT_LANGUAGE', + 'HTTP_CONNECTION', + 'HTTP_HOST', + 'HTTP_REFERER', + 'HTTP_USER_AGENT', + 'HTTPS', + 'REQUEST_METHOD', + 'REQUEST_URI', + 'SERVER_NAME', + ], + ]; /** * Get a configuration setting. @@ -81,23 +109,25 @@ class Liquid * * @return string */ - public static function get($key) { + public static function get($key) + { + // backward compatibility + if ($key === 'ALLOWED_VARIABLE_CHARS') { + return substr(self::$config['VARIABLE_NAME'], 0, -1); + } if (array_key_exists($key, self::$config)) { return self::$config[$key]; - } else { - // This case is needed for compound settings - switch ($key) { - case 'QUOTED_FRAGMENT': - return self::$config['QUOTED_STRING'] . '|(?:[^\s,\|\'"]|' . self::$config['QUOTED_STRING'] . ')+'; - case 'QUOTED_FRAGMENT_FILTER_ARGUMENT': - return self::$config['QUOTED_STRING_FILTER_ARGUMENT'] . '|(?:[^\s:,\|\'"]|' . self::$config['QUOTED_STRING_FILTER_ARGUMENT'] . ')+'; - case 'TAG_ATTRIBUTES': - return '/(\w+)\s*\:\s*(' . self::get('QUOTED_FRAGMENT') . ')/'; - case 'TOKENIZATION_REGEXP': - return '/(' . self::$config['TAG_START'] . '.*?' . self::$config['TAG_END'] . '|' . self::$config['VARIABLE_START'] . '.*?' . self::$config['VARIABLE_END'] . ')/'; - default: - return null; - } + } + // This case is needed for compound settings + switch ($key) { + case 'QUOTED_FRAGMENT': + return '(?:' . self::get('QUOTED_STRING') . '|[^\s,\|\'"]+)'; + case 'TAG_ATTRIBUTES': + return '/(\w+)\s*\:\s*(' . self::get('QUOTED_FRAGMENT') . ')/'; + case 'TOKENIZATION_REGEXP': + return '/(' . self::$config['TAG_START'] . '.*?' . self::$config['TAG_END'] . '|' . self::$config['VARIABLE_START'] . '.*?' . self::$config['VARIABLE_END'] . ')/s'; + default: + return null; } } @@ -107,7 +137,13 @@ public static function get($key) { * @param string $key * @param string $value */ - public static function set($key, $value) { + public static function set($key, $value) + { + // backward compatibility + if ($key === 'ALLOWED_VARIABLE_CHARS') { + $key = 'VARIABLE_NAME'; + $value .= '+'; + } self::$config[$key] = $value; } @@ -118,8 +154,9 @@ public static function set($key, $value) { * * @return array */ - public static function arrayFlatten($array) { - $return = array(); + public static function arrayFlatten($array) + { + $return = []; foreach ($array as $element) { if (is_array($element)) { diff --git a/src/Liquid/LiquidException.php b/src/Liquid/LiquidException.php index 198e2353..402c69d4 100644 --- a/src/Liquid/LiquidException.php +++ b/src/Liquid/LiquidException.php @@ -1,6 +1,6 @@ root = $root; - } - - /** - * Retrieve a template file - * - * @param string $templatePath - * - * @throws LiquidException - * @return string template content - */ - public function readTemplateFile($templatePath) { - if (!($fullPath = $this->fullPath($templatePath))) { - throw new LiquidException("No such template '$templatePath'"); - } - - return file_get_contents($fullPath); - } - - /** - * Resolves a given path to a full template file path, making sure it's valid - * - * @param string $templatePath - * - * @throws LiquidException - * @return string - */ - public function fullPath($templatePath) { - $nameRegex = Liquid::get('INCLUDE_ALLOW_EXT') - ? new Regexp('/^[^.\/][a-zA-Z0-9_\.\/]+$/') - : new Regexp('/^[^.\/][a-zA-Z0-9_\/]+$/'); - - if (!$nameRegex->match($templatePath)) { - throw new LiquidException("Illegal template name '$templatePath'"); - } - - if (strpos($templatePath, '/') !== false) { - $fullPath = Liquid::get('INCLUDE_ALLOW_EXT') - ? $this->root . dirname($templatePath) . '/' . basename($templatePath) - : $this->root . dirname($templatePath) . '/' . Liquid::get('INCLUDE_PREFIX') . basename($templatePath) . '.' . Liquid::get('INCLUDE_SUFFIX'); - } else { - $fullPath = Liquid::get('INCLUDE_ALLOW_EXT') - ? $this->root . $templatePath - : $this->root . Liquid::get('INCLUDE_PREFIX') . $templatePath . '.' . Liquid::get('INCLUDE_SUFFIX'); - } - - $rootRegex = new Regexp('/' . preg_quote(realpath($this->root), '/') . '/'); - - if (!$rootRegex->match(realpath($fullPath))) { - throw new LiquidException("Illegal template path '" . realpath($fullPath) . "'"); - } - - return $fullPath; - } } diff --git a/src/Liquid/Regexp.php b/src/Liquid/Regexp.php index 45b79b23..70a499ae 100644 --- a/src/Liquid/Regexp.php +++ b/src/Liquid/Regexp.php @@ -1,6 +1,6 @@ pattern = (substr($pattern, '0', 1) != '/') + public function __construct($pattern) + { + $this->pattern = (substr($pattern, 0, 1) != '/') ? '/' . $this->quote($pattern) . '/' : $pattern; } @@ -51,7 +52,8 @@ public function __construct($pattern) { * * @return string */ - public function quote($string) { + public function quote($string) + { return preg_quote($string, '/'); } @@ -62,7 +64,8 @@ public function quote($string) { * * @return array */ - public function scan($string) { + public function scan($string) + { preg_match_all($this->pattern, $string, $matches); if (count($matches) == 1) { @@ -71,7 +74,7 @@ public function scan($string) { array_shift($matches); - $result = array(); + $result = []; foreach ($matches as $matchKey => $subMatches) { foreach ($subMatches as $subMatchKey => $subMatch) { @@ -89,7 +92,8 @@ public function scan($string) { * * @return int 1 if there was a match, 0 if there wasn't */ - public function match($string) { + public function match($string) + { return preg_match($this->pattern, $string, $this->matches); } @@ -100,7 +104,8 @@ public function match($string) { * * @return int The number of matches */ - public function matchAll($string) { + public function matchAll($string) + { return preg_match_all($this->pattern, $string, $this->matches); } @@ -112,7 +117,18 @@ public function matchAll($string) { * * @return array */ - public function split($string, $limit = null) { + public function split($string, $limit = -1) + { return preg_split($this->pattern, $string, $limit); } + + /** + * Returns the original pattern primarily for debugging purposes + * + * @return string + */ + public function __toString() + { + return $this->pattern; + } } diff --git a/src/Liquid/StandardFilters.php b/src/Liquid/StandardFilters.php index de7bce98..086b517f 100644 --- a/src/Liquid/StandardFilters.php +++ b/src/Liquid/StandardFilters.php @@ -1,6 +1,6 @@ format($dateFormat); + + return $formatted; + } - return strftime($format, $input); - } - - /** * Default * @@ -83,25 +103,27 @@ public static function date($input, $format) { * * @return string */ - public static function _default($input, $default_value) { + public static function _default($input, $default_value) + { $isBlank = $input == '' || $input === false || $input === null; return $isBlank ? $default_value : $input; } - - + + /** * division * - * @param int $input - * @param int $operand + * @param float $input + * @param float $operand * - * @return int + * @return float */ - public static function divided_by($input, $operand) { - return (int)$input / (int)$operand; + public static function divided_by($input, $operand) + { + return (float)$input / (float)$operand; } - - + + /** * Convert an input to lowercase * @@ -109,11 +131,12 @@ public static function divided_by($input, $operand) { * * @return string */ - public static function downcase($input) { - return is_string($input) ? strtolower($input) : $input; + public static function downcase($input) + { + return is_string($input) ? mb_strtolower($input) : $input; } - - + + /** * Pseudo-filter: negates auto-added escape filter * @@ -121,7 +144,46 @@ public static function downcase($input) { * * @return string */ - public static function raw($input) { + public static function raw($input) + { + return $input; + } + + + /** + * Converts into JSON string + * + * @param mixed $input + * + * @return string + */ + public static function json($input) + { + return json_encode($input); + } + + /** + * Creates an array including only the objects with a given property value + * @link https://shopify.github.io/liquid/filters/where/ + * + * @param mixed $input + * @param string ...$args + * + * @throws LiquidException + * @return mixed + */ + public static function where($input, string ...$args) + { + if (is_array($input)) { + switch (count($args)) { + case 1: + return array_values(array_filter($input, fn ($v) => !in_array($v[$args[0]] ?? null, [null, false], true))); + case 2: + return array_values(array_filter($input, fn ($v) => ($v[$args[0]] ?? '') == $args[1])); + default: + throw new LiquidException('Wrong number of arguments to function `where`, given ' . count($args) . ', expected 1 or 2'); + } + } return $input; } @@ -132,8 +194,18 @@ public static function raw($input) { * * @return string */ - public static function escape($input) { - return is_string($input) ? htmlentities($input, ENT_QUOTES) : $input; + public static function escape($input) + { + // Arrays are taken care down the stack with an error + if (is_array($input)) { + return $input; + } + + if (is_null($input)) { + return ''; + } + + return htmlentities($input, ENT_QUOTES); } @@ -144,114 +216,155 @@ public static function escape($input) { * * @return string */ - public static function escape_once($input) { - return is_string($input) ? htmlentities($input, ENT_QUOTES, null, false) : $input; + public static function escape_once($input) + { + // Arrays are taken care down the stack with an error + if (is_array($input)) { + return $input; + } + + return htmlentities($input, ENT_QUOTES, null, false); } /** * Returns the first element of an array * - * @param array $input + * @param array|\Iterator $input * * @return mixed */ - public static function first($input) { + public static function first($input) + { + if ($input instanceof \Iterator) { + $input->rewind(); + return $input->current(); + } return is_array($input) ? reset($input) : $input; - } - - + } + + /** * @param mixed $input number * * @return int */ - public static function floor($input) { + public static function floor($input) + { return (int) floor((float)$input); - } - - + } + + /** * Joins elements of an array with a given character between them * - * @param array $input + * @param array|\Traversable $input * @param string $glue * * @return string */ - public static function join($input, $glue = ' ') { + public static function join($input, $glue = ' ') + { + if ($input instanceof \Traversable) { + $str = ''; + foreach ($input as $elem) { + if ($str) { + $str .= $glue; + } + $str .= $elem; + } + return $str; + } return is_array($input) ? implode($glue, $input) : $input; } - - + + /** * Returns the last element of an array * - * @param array $input + * @param array|\Traversable $input * * @return mixed */ - public static function last($input) { + public static function last($input) + { + if ($input instanceof \Traversable) { + $last = null; + foreach ($input as $elem) { + $last = $elem; + } + return $last; + } return is_array($input) ? end($input) : $input; - } - + } + /** * @param string $input * * @return string */ - public static function lstrip($input) { + public static function lstrip($input) + { return ltrim($input); - } - - + } + + /** * Map/collect on a given property * - * @param array $input + * @param array|\Traversable $input * @param string $property * * @return string */ - public static function map(array $input, $property) { - return array_reduce($input, function($result, $elem) use ($property) { + public static function map($input, $property) + { + if ($input instanceof \Traversable) { + $input = iterator_to_array($input); + } + if (!is_array($input)) { + return $input; + } + return array_map(function ($elem) use ($property) { if (is_callable($elem)) { - return $result . $elem(); + return $elem(); } elseif (is_array($elem) && array_key_exists($property, $elem)) { - return $result . $elem[$property]; + return $elem[$property]; } - return $result . ''; - }, ''); + return null; + }, $input); } - + /** * subtraction * - * @param int $input - * @param int $operand + * @param float $input + * @param float $operand * - * @return int + * @return float */ - public static function minus($input, $operand) { - return (int)$input - (int)$operand; + public static function minus($input, $operand) + { + return (float)$input - (float)$operand; } - - + + /** * modulo * - * @param int $input - * @param int $operand + * @param float $input + * @param float $operand * - * @return int + * @return float */ - public static function modulo($input, $operand) { - return (int)$input % (int)$operand; - } - - + public static function modulo($input, $operand) + { + return fmod((float)$input, (float)$operand); + } + + /** * Replace each newline (\n) with html break * @@ -259,25 +372,25 @@ public static function modulo($input, $operand) { * * @return string */ - public static function newline_to_br($input) { - return is_string($input) ? str_replace(array( - "\n", "\r" - ), '
', $input) : $input; - } - + public static function newline_to_br($input) + { + return is_string($input) ? str_replace("\n", "
\n", $input) : $input; + } + /** * addition * - * @param int $input - * @param int $operand + * @param float $input + * @param float $operand * - * @return int + * @return float */ - public static function plus($input, $operand) { - return (int)$input + (int)$operand; - } - + public static function plus($input, $operand) + { + return (float)$input + (float)$operand; + } + /** * Prepend a string to another @@ -287,10 +400,11 @@ public static function plus($input, $operand) { * * @return string */ - public static function prepend($input, $string) { + public static function prepend($input, $string) + { return $string . $input; } - + /** * Remove a substring @@ -300,7 +414,8 @@ public static function prepend($input, $string) { * * @return string */ - public static function remove($input, $string) { + public static function remove($input, $string) + { return str_replace($string, '', $input); } @@ -313,14 +428,15 @@ public static function remove($input, $string) { * * @return string */ - public static function remove_first($input, $string) { + public static function remove_first($input, $string) + { if (($pos = strpos($input, $string)) !== false) { $input = substr_replace($input, '', $pos, strlen($string)); } return $input; - } - + } + /** * Replace occurrences of a string with another @@ -331,7 +447,8 @@ public static function remove_first($input, $string) { * * @return string */ - public static function replace($input, $string, $replacement = '') { + public static function replace($input, $string, $replacement = '') + { return str_replace($string, $replacement, $input); } @@ -345,27 +462,32 @@ public static function replace($input, $string, $replacement = '') { * * @return string */ - public static function replace_first($input, $string, $replacement = '') { + public static function replace_first($input, $string, $replacement = '') + { if (($pos = strpos($input, $string)) !== false) { $input = substr_replace($input, $replacement, $pos, strlen($string)); } return $input; } - - + + /** * Reverse the elements of an array * - * @param array $input + * @param array|\Traversable $input * * @return array */ - public static function reverse(array $input) { + public static function reverse($input) + { + if ($input instanceof \Traversable) { + $input = iterator_to_array($input); + } return array_reverse($input); } - - + + /** * Round a number * @@ -374,90 +496,120 @@ public static function reverse(array $input) { * * @return float */ - public static function round($input, $n = 0) { + public static function round($input, $n = 0) + { return round((float)$input, (int)$n); - } - - + } + + /** * @param string $input * * @return string */ - public static function rstrip($input) { + public static function rstrip($input) + { return rtrim($input); - } - - + } + + /** * Return the size of an array or of an string * * @param mixed $input - * + * @throws RenderException * @return int */ - public static function size($input) { - if (is_string($input) || is_numeric($input)) { - return strlen($input); - } elseif (is_array($input)) { + public static function size($input) + { + if ($input instanceof \Iterator) { + return iterator_count($input); + } + + if (is_array($input)) { return count($input); - } elseif (is_object($input)) { + } + + if (is_object($input)) { if (method_exists($input, 'size')) { return $input->size(); } + + if (!method_exists($input, '__toString')) { + $class = get_class($input); + throw new RenderException("Size of $class cannot be estimated: it has no method 'size' nor can be converted to a string"); + } } - return $input; + // only plain values and stringable objects left at this point + return strlen($input); } - + /** - * @param array|string $input + * @param array|\Iterator|string $input * @param int $offset * @param int $length * - * @return array|string + * @return array|\Iterator|string */ - public static function slice($input, $offset, $length = null) { + public static function slice($input, $offset, $length = null) + { + if ($input instanceof \Iterator) { + $input = iterator_to_array($input); + } if (is_array($input)) { $input = array_slice($input, $offset, $length); } elseif (is_string($input)) { - $input = $length === null - ? substr($input, $offset) - : substr($input, $offset, $length); + $input = mb_substr($input, $offset, $length); } return $input; - } - - + } + + /** * Sort the elements of an array * - * @param array $input + * @param array|\Traversable $input * @param string $property use this property of an array element * * @return array */ - public static function sort(array $input, $property = null) { + public static function sort($input, $property = null) + { + if ($input instanceof \Traversable) { + $input = iterator_to_array($input); + } if ($property === null) { asort($input); } else { $first = reset($input); if ($first !== false && is_array($first) && array_key_exists($property, $first)) { - uasort($input, function($a, $b) use ($property) { - if ($a[$property] == $b[$property]) { + uasort($input, function ($a, $b) use ($property) { + if (($a[$property] ?? 0) == ($b[$property] ?? 0)) { return 0; } - return $a[$property] < $b[$property] ? -1 : 1; + return ($a[$property] ?? 0) < ($b[$property] ?? 0) ? -1 : 1; }); } } return $input; - } - + } + + /** + * Explicit string conversion. + * + * @param mixed $input + * + * @return string + */ + public static function string($input) + { + return strval($input); + } /** * Split input string into an array of substrings separated by given pattern. @@ -467,7 +619,16 @@ public static function sort(array $input, $property = null) { * * @return array */ - public static function split($input, $pattern) { + public static function split($input, $pattern) + { + if ($input === '' || $input === null) { + return []; + } + + if ($pattern === '') { + return mb_str_split($input); + } + return explode($pattern, $input); } @@ -477,11 +638,12 @@ public static function split($input, $pattern) { * * @return string */ - public static function strip($input) { + public static function strip($input) + { return trim($input); } - - + + /** * Removes html tags from text * @@ -489,10 +651,11 @@ public static function strip($input) { * * @return string */ - public static function strip_html($input) { + public static function strip_html($input) + { return is_string($input) ? strip_tags($input) : $input; } - + /** * Strip all newlines (\n, \r) from string @@ -501,25 +664,27 @@ public static function strip_html($input) { * * @return string */ - public static function strip_newlines($input) { - return is_string($input) ? str_replace(array( - "\n", "\r" - ), '', $input) : $input; - } - + public static function strip_newlines($input) + { + return is_string($input) ? str_replace([ + "\n", "\r", + ], '', $input) : $input; + } + /** * multiplication * - * @param int $input - * @param int $operand + * @param float $input + * @param float $operand * - * @return int + * @return float */ - public static function times($input, $operand) { - return (int)$input * (int)$operand; + public static function times($input, $operand) + { + return (float)$input * (float)$operand; } - + /** * Truncate a string down to x characters @@ -530,16 +695,17 @@ public static function times($input, $operand) { * * @return string */ - public static function truncate($input, $characters = 100, $ending = '...') { + public static function truncate($input, $characters = 100, $ending = '...') + { if (is_string($input) || is_numeric($input)) { if (strlen($input) > $characters) { - return substr($input, 0, $characters) . $ending; + return mb_substr($input, 0, $characters) . $ending; } } return $input; } - + /** * Truncate string down to x words @@ -550,7 +716,8 @@ public static function truncate($input, $characters = 100, $ending = '...') { * * @return string */ - public static function truncatewords($input, $words = 3, $ending = '...') { + public static function truncatewords($input, $words = 3, $ending = '...') + { if (is_string($input)) { $wordlist = explode(" ", $input); @@ -560,20 +727,25 @@ public static function truncatewords($input, $words = 3, $ending = '...') { } return $input; - } - + } + /** * Remove duplicate elements from an array * - * @param array $input + * @param array|\Traversable $input * * @return array */ - public static function uniq(array $input) { + public static function uniq($input) + { + if ($input instanceof \Traversable) { + $input = iterator_to_array($input); + } return array_unique($input); } - + + /** * Convert an input to uppercase * @@ -581,10 +753,12 @@ public static function uniq(array $input) { * * @return string */ - public static function upcase($input) { - return is_string($input) ? strtoupper($input) : $input; + public static function upcase($input) + { + return is_string($input) ? mb_strtoupper($input) : $input; } + /** * URL encodes a string * @@ -592,24 +766,20 @@ public static function upcase($input) { * * @return string */ - public static function url_encode($input) { + public static function url_encode($input) + { return urlencode($input); } - - + /** - * Use overloading to get around reserved php words - in this case 'default' + * Decodes a URL-encoded string * - * @param string $name - * @param array $arguments + * @param string $input * * @return string - * */ - public function __call($name, $arguments) { - if ($name === 'default') { - return $this->_default($arguments[0], $arguments[1]); - } - } - + public static function url_decode($input) + { + return urldecode($input); + } } diff --git a/src/Liquid/Tag/TagAssign.php b/src/Liquid/Tag/TagAssign.php index a9af9781..7fb54bed 100644 --- a/src/Liquid/Tag/TagAssign.php +++ b/src/Liquid/Tag/TagAssign.php @@ -1,6 +1,6 @@ filters = array(); - - if ($filterSeperatorRegexp->match($markup)) { - $filters = $filterSplitRegexp->split($filterSeperatorRegexp->matches[1]); - - foreach ($filters as $filter) { - $filterNameRegexp->match($filter); - $filtername = $filterNameRegexp->matches[1]; - - $filterArgumentRegexp->matchAll($filter); - $matches = Liquid::arrayFlatten($filterArgumentRegexp->matches[1]); + public function __construct($markup, array &$tokens, ?FileSystem $fileSystem = null) + { + parent::__construct($markup, $tokens, $fileSystem); - array_push($this->filters, array($filtername, $matches)); - } - } + $syntaxRegexp = new Regexp('/(\w+)\s*=\s*(.*)\s*/'); if ($syntaxRegexp->match($markup)) { $this->to = $syntaxRegexp->matches[1]; - $this->from = $syntaxRegexp->matches[2]; + $this->from = new Variable($syntaxRegexp->matches[2]); } else { - throw new LiquidException("Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"); + throw new ParseException("Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"); } } @@ -86,20 +69,9 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @return string|void */ - public function render(Context $context) { - $output = $context->get($this->from); - - foreach ($this->filters as $filter) { - list($filtername, $filterArgKeys) = $filter; - - $filterArgValues = array(); - - foreach ($filterArgKeys as $arg_key) { - $filterArgValues[] = $context->get($arg_key); - } - - $output = $context->invoke($filtername, $output, $filterArgValues); - } + public function render(Context $context) + { + $output = $this->from->render($context); $context->set($this->to, $output, true); } diff --git a/src/Liquid/Tag/TagBlock.php b/src/Liquid/Tag/TagBlock.php index ee29b90a..188e13b2 100644 --- a/src/Liquid/Tag/TagBlock.php +++ b/src/Liquid/Tag/TagBlock.php @@ -1,6 +1,6 @@ match($markup)) { $this->block = $syntaxRegexp->matches[1]; parent::__construct($markup, $tokens, $fileSystem); } else { - throw new LiquidException("Syntax Error in 'block' - Valid syntax: block [name]"); + throw new ParseException("Syntax Error in 'block' - Valid syntax: block [name]"); } } } diff --git a/src/Liquid/Tag/TagBreak.php b/src/Liquid/Tag/TagBreak.php new file mode 100644 index 00000000..5a168288 --- /dev/null +++ b/src/Liquid/Tag/TagBreak.php @@ -0,0 +1,42 @@ +registers['break'] = true; + } +} diff --git a/src/Liquid/Tag/TagCapture.php b/src/Liquid/Tag/TagCapture.php index c6d356c4..afcd1a02 100644 --- a/src/Liquid/Tag/TagCapture.php +++ b/src/Liquid/Tag/TagCapture.php @@ -1,6 +1,6 @@ match($markup)) { $this->to = $syntaxRegexp->matches[1]; parent::__construct($markup, $tokens, $fileSystem); } else { - throw new LiquidException("Syntax Error in 'capture' - Valid syntax: capture [var] [value]"); + throw new ParseException("Syntax Error in 'capture' - Valid syntax: capture [var] [value]"); } } @@ -60,10 +61,11 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $output = parent::render($context); - $context->set($this->to, $output); + $context->set($this->to, $output, true); return ''; } } diff --git a/src/Liquid/Tag/TagCase.php b/src/Liquid/Tag/TagCase.php index 9f131a2b..b2f4d2a4 100644 --- a/src/Liquid/Tag/TagCase.php +++ b/src/Liquid/Tag/TagCase.php @@ -1,6 +1,6 @@ nodelists = array(); - $this->elseNodelist = array(); + public function __construct($markup, array &$tokens, ?FileSystem $fileSystem = null) + { + $this->nodelists = []; + $this->elseNodelist = []; parent::__construct($markup, $tokens, $fileSystem); @@ -75,14 +76,15 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu if ($syntaxRegexp->match($markup)) { $this->left = $syntaxRegexp->matches[0]; } else { - throw new LiquidException("Syntax Error in tag 'case' - Valid syntax: case [condition]"); // harry + throw new ParseException("Syntax Error in tag 'case' - Valid syntax: case [condition]"); // harry } } /** * Pushes the last nodelist onto the stack */ - public function endTag() { + public function endTag() + { $this->pushNodelist(); } @@ -93,30 +95,29 @@ public function endTag() { * @param string $params * @param array $tokens * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\ParseException */ - public function unknownTag($tag, $params, array $tokens) { - $whenSyntaxRegexp = new Regexp('/' . Liquid::get('QUOTED_FRAGMENT') . '/'); - + public function unknownTag($tag, $params, array $tokens) + { switch ($tag) { case 'when': + $whenSyntax = preg_match_all('/(?<=,|or|^)\s*(' . Liquid::get('QUOTED_FRAGMENT') . ')/', $params, $matches); // push the current nodelist onto the stack and prepare for a new one - if ($whenSyntaxRegexp->match($params)) { + if ($whenSyntax) { $this->pushNodelist(); - $this->right = $whenSyntaxRegexp->matches[0]; - $this->nodelist = array(); - + $this->right = $matches[1]; + $this->nodelist = []; } else { - throw new LiquidException("Syntax Error in tag 'case' - Valid when condition: when [condition]"); // harry + throw new ParseException("Syntax Error in tag 'case' - Valid when condition: when [condition]"); // harry } break; case 'else': - // push the last nodelist onto the stack and prepare to recieve the else nodes + // push the last nodelist onto the stack and prepare to receive the else nodes $this->pushNodelist(); $this->right = null; $this->elseNodelist = &$this->nodelist; - $this->nodelist = array(); + $this->nodelist = []; break; default: @@ -127,9 +128,10 @@ public function unknownTag($tag, $params, array $tokens) { /** * Pushes the current right value and nodelist into the nodelist stack */ - public function pushNodelist() { + public function pushNodelist() + { if (!is_null($this->right)) { - $this->nodelists[] = array($this->right, $this->nodelist); + $this->nodelists[] = [$this->right, $this->nodelist]; } } @@ -140,19 +142,24 @@ public function pushNodelist() { * * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $output = ''; // array(); $runElseBlock = true; foreach ($this->nodelists as $data) { list($right, $nodelist) = $data; - if ($this->equalVariables($this->left, $right, $context)) { - $runElseBlock = false; + foreach ($right as $var) { + if ($this->equalVariables($this->left, $var, $context)) { + $runElseBlock = false; - $context->push(); - $output .= $this->renderAll($nodelist, $context); - $context->pop(); + $context->push(); + $output .= $this->renderAll($nodelist, $context); + $context->pop(); + + break; + } } } diff --git a/src/Liquid/Tag/TagComment.php b/src/Liquid/Tag/TagComment.php index 0451fb12..e41cc064 100644 --- a/src/Liquid/Tag/TagComment.php +++ b/src/Liquid/Tag/TagComment.php @@ -1,6 +1,6 @@ registers['continue'] = true; + } +} diff --git a/src/Liquid/Tag/TagCycle.php b/src/Liquid/Tag/TagCycle.php index a3b0487b..51d9fcad 100644 --- a/src/Liquid/Tag/TagCycle.php +++ b/src/Liquid/Tag/TagCycle.php @@ -1,6 +1,6 @@ variables = $this->variablesFromString($markup); $this->name = "'" . implode($this->variables) . "'"; } else { - throw new LiquidException("Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"); + throw new ParseException("Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"); } } @@ -76,7 +79,8 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * @var Context $context * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $context->push(); $key = $context->get($this->name); @@ -109,10 +113,11 @@ public function render(Context $context) { * * @return array; */ - private function variablesFromString($markup) { + private function variablesFromString($markup) + { $regexp = new Regexp('/\s*(' . Liquid::get('QUOTED_FRAGMENT') . ')\s*/'); $parts = explode(',', $markup); - $result = array(); + $result = []; foreach ($parts as $part) { $regexp->match($part); diff --git a/src/Liquid/Tag/TagDecrement.php b/src/Liquid/Tag/TagDecrement.php index 23cad030..30b230d2 100644 --- a/src/Liquid/Tag/TagDecrement.php +++ b/src/Liquid/Tag/TagDecrement.php @@ -1,6 +1,6 @@ match($markup)) { $this->toDecrement = $syntax->matches[0]; } else { - throw new LiquidException("Syntax Error in 'decrement' - Valid syntax: decrement [var]"); + throw new ParseException("Syntax Error in 'decrement' - Valid syntax: decrement [var]"); } } @@ -62,7 +65,8 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @return string|void */ - public function render(Context $context) { + public function render(Context $context) + { // if the value is not set in the environment check to see if it // exists in the context, and if not set it to 0 if (!isset($context->environments[0][$this->toDecrement])) { diff --git a/src/Liquid/Tag/TagExtends.php b/src/Liquid/Tag/TagExtends.php index aea841ee..417d9fd4 100644 --- a/src/Liquid/Tag/TagExtends.php +++ b/src/Liquid/Tag/TagExtends.php @@ -1,6 +1,6 @@ match($markup)) { + if ($regex->match($markup) && isset($regex->matches[1])) { $this->templateName = substr($regex->matches[1], 1, strlen($regex->matches[1]) - 2); } else { - throw new LiquidException("Error in tag 'extends' - Valid syntax: extends '[template name]'"); + throw new ParseException("Error in tag 'extends' - Valid syntax: extends '[template name]'"); } parent::__construct($markup, $tokens, $fileSystem); @@ -70,18 +72,25 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @return array */ - private function findBlocks(array $tokens) { + private function findBlocks(array $tokens) + { $blockstartRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*block (\w+)\s*(.*)?' . Liquid::get('TAG_END') . '$/'); $blockendRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*endblock\s*?' . Liquid::get('TAG_END') . '$/'); - $b = array(); + $b = []; $name = null; - foreach ($tokens as $token) { + for ($i = 0, $n = count($tokens); $i < $n; $i++) { + if ($tokens[$i] === null) { + continue; + } + $token = $tokens[$i]; + $tokens[$i] = null; + if ($blockstartRegexp->match($token)) { $name = $blockstartRegexp->matches[1]; - $b[$name] = array(); - } else if ($blockendRegexp->match($token)) { + $b[$name] = []; + } elseif ($blockendRegexp->match($token)) { $name = null; } else { if ($name !== null) { @@ -98,11 +107,12 @@ private function findBlocks(array $tokens) { * * @param array $tokens * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\MissingFilesystemException */ - public function parse(array &$tokens) { + public function parse(array &$tokens) + { if ($this->fileSystem === null) { - throw new LiquidException("No file system"); + throw new MissingFilesystemException("No file system"); } // read the source of the template and create a new sub document @@ -112,11 +122,12 @@ public function parse(array &$tokens) { $maintokens = Template::tokenize($source); $eRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*extends (.*)?' . Liquid::get('TAG_END') . '$/'); - foreach ($maintokens as $maintoken) + foreach ($maintokens as $maintoken) { if ($eRegexp->match($maintoken)) { $m = $eRegexp->matches[1]; break; } + } if (isset($m)) { $rest = array_merge($maintokens, $tokens); @@ -128,63 +139,64 @@ public function parse(array &$tokens) { $name = null; - $rest = array(); - $aufzeichnen = false; + $rest = []; + $keep = false; for ($i = 0; $i < count($maintokens); $i++) { if ($blockstartRegexp->match($maintokens[$i])) { $name = $blockstartRegexp->matches[1]; if (isset($childtokens[$name])) { - $aufzeichnen = true; + $keep = true; array_push($rest, $maintokens[$i]); foreach ($childtokens[$name] as $item) { array_push($rest, $item); } } - } - if (!$aufzeichnen) { + if (!$keep) { array_push($rest, $maintokens[$i]); } - if ($blockendRegexp->match($maintokens[$i]) && $aufzeichnen === true) { - $aufzeichnen = false; + if ($blockendRegexp->match($maintokens[$i]) && $keep === true) { + $keep = false; array_push($rest, $maintokens[$i]); } } } + $cache = Template::getCache(); + + if (!$cache) { + $this->document = new Document($rest, $this->fileSystem); + return; + } + $this->hash = md5($source); - $cache = Template::getCache(); + $this->document = $cache->read($this->hash); - if (isset($cache)) { - if (($this->document = $cache->read($this->hash)) != false && $this->document->checkIncludes() != true) { - } else { - $this->document = new Document($rest, $this->fileSystem); - $cache->write($this->hash, $this->document); - } - } else { + if ($this->document == false || $this->document->hasIncludes() == true) { $this->document = new Document($rest, $this->fileSystem); + $cache->write($this->hash, $this->document); } } /** - * Check for cached includes + * Check for cached includes; if there are - do not use cache * + * @see Document::hasIncludes() * @return boolean */ - public function checkIncludes() { - $cache = Template::getCache(); - - if ($this->document->checkIncludes() == true) { + public function hasIncludes() + { + if ($this->document->hasIncludes() == true) { return true; } $source = $this->fileSystem->readTemplateFile($this->templateName); - if ($cache->exists(md5($source)) && $this->hash == md5($source)) { + if (Template::getCache()->exists(md5($source)) && $this->hash === md5($source)) { return false; } @@ -198,7 +210,8 @@ public function checkIncludes() { * * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $context->push(); $result = $this->document->render($context); $context->pop(); diff --git a/src/Liquid/Tag/TagFor.php b/src/Liquid/Tag/TagFor.php index 7096d301..9b616acb 100644 --- a/src/Liquid/Tag/TagFor.php +++ b/src/Liquid/Tag/TagFor.php @@ -1,6 +1,6 @@ match($markup)) { - $this->variableName = $syntaxRegexp->matches[1]; $this->collectionName = $syntaxRegexp->matches[2]; $this->name = $syntaxRegexp->matches[1] . '-' . $syntaxRegexp->matches[2]; $this->extractAttributes($markup); - } else { - - $syntaxRegexp = new Regexp('/(\w+)\s+in\s+\((\d|'.Liquid::get('ALLOWED_VARIABLE_CHARS').'+)\s*..\s*(\d|'.Liquid::get('ALLOWED_VARIABLE_CHARS').'+)\)/'); + $syntaxRegexp = new Regexp('/(\w+)\s+in\s+\((\d+|' . Liquid::get('VARIABLE_NAME') . ')\s*\.\.\s*(\d+|' . Liquid::get('VARIABLE_NAME') . ')\)/'); if ($syntaxRegexp->match($markup)) { $this->type = 'digit'; $this->variableName = $syntaxRegexp->matches[1]; @@ -86,8 +89,8 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu $this->collectionName = $syntaxRegexp->matches[3]; $this->name = $syntaxRegexp->matches[1].'-digit'; $this->extractAttributes($markup); - } else { - throw new LiquidException("Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"); + } else { + throw new ParseException("Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"); } } } @@ -99,108 +102,136 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @return null|string */ - public function render(Context $context) { - + public function render(Context $context) + { if (!isset($context->registers['for'])) { - $context->registers['for'] = array(); + $context->registers['for'] = []; + } + + if ($this->type == 'digit') { + return $this->renderDigit($context); + } + + // that's the default + return $this->renderCollection($context); + } + + private function renderCollection(Context $context) + { + $collection = $context->get($this->collectionName); + + if ($collection instanceof \Generator && !$collection->valid()) { + return ''; + } + + if ($collection instanceof \Traversable) { + $collection = iterator_to_array($collection); + } + + if (is_null($collection) || !is_array($collection) || count($collection) == 0) { + return ''; + } + + $range = [0, count($collection)]; + + if (isset($this->attributes['limit']) || isset($this->attributes['offset'])) { + $offset = 0; + + if (isset($this->attributes['offset'])) { + $offset = ($this->attributes['offset'] == 'continue') ? $context->registers['for'][$this->name] : $context->get($this->attributes['offset']); + } + + $limit = (isset($this->attributes['limit'])) ? $context->get($this->attributes['limit']) : null; + $rangeEnd = $limit ? $limit : count($collection) - $offset; + $range = [$offset, $rangeEnd]; + + $context->registers['for'][$this->name] = $rangeEnd + $offset; + } + + $result = ''; + $segment = array_slice($collection, $range[0], $range[1]); + if (!count($segment)) { + return null; } - - switch ($this->type){ - - case 'collection': - - $collection = $context->get($this->collectionName); - - if (is_null($collection) || !is_array($collection) || count($collection) == 0) { - return ''; - } - - $range = array(0, count($collection)); - - if (isset($this->attributes['limit']) || isset($this->attributes['offset'])) { - $offset = 0; - - if (isset($this->attributes['offset'])) { - $offset = ($this->attributes['offset'] == 'continue') ? $context->registers['for'][$this->name] : $context->get($this->attributes['offset']); - } - - $limit = (isset($this->attributes['limit'])) ? $context->get($this->attributes['limit']) : null; - $rangeEnd = $limit ? $limit : count($collection) - $offset; - $range = array($offset, $rangeEnd); - - $context->registers['for'][$this->name] = $rangeEnd + $offset; - } - - $result = ''; - $segment = array_slice($collection, $range[0], $range[1]); - if (!count($segment)) { - return null; - } - - $context->push(); - $length = count($segment); - - $index = 0; - foreach ($segment as $key => $item) { - $value = is_numeric($key) ? $item : array($key, $item); - $context->set($this->variableName, $value); - $context->set('forloop', array( - 'name' => $this->name, - 'length' => $length, - 'index' => $index + 1, - 'index0' => $index, - 'rindex' => $length - $index, - 'rindex0' => $length - $index - 1, - 'first' => (int)($index == 0), - 'last' => (int)($index == $length - 1) - )); - - $result .= $this->renderAll($this->nodelist, $context); - - $index++; - } - - break; - - case 'digit': - - $start = $this->start; - if (!is_integer($this->start)) { - $start = $context->get($this->start); - } - - $end = $this->collectionName; - if (!is_integer($this->collectionName)) { - $end = $context->get($this->collectionName); - } - - $range = array($start, $end); - - $context->push(); - $result = ''; - $index = 0; - $length = $range[1] - $range[0]; - for ($i=$range[0]; $i<=$range[1]; $i++) { - - $context->set($this->variableName, $i); - $context->set('forloop', array( - 'name' => $this->name, - 'length' => $length, - 'index' => $index + 1, - 'index0' => $index, - 'rindex' => $length - $index, - 'rindex0' => $length - $index - 1, - 'first' => (int)($index == 0), - 'last' => (int)($index == $length - 1) - )); - - $result .= $this->renderAll($this->nodelist, $context); - - $index++; - } - - break; - + + $context->push(); + $length = count($segment); + + $index = 0; + foreach ($segment as $key => $item) { + $value = is_numeric($key) ? $item : [$key, $item]; + $context->set($this->variableName, $value); + $context->set('forloop', [ + 'name' => $this->name, + 'length' => $length, + 'index' => $index + 1, + 'index0' => $index, + 'rindex' => $length - $index, + 'rindex0' => $length - $index - 1, + 'first' => (int)($index == 0), + 'last' => (int)($index == $length - 1), + ]); + + $result .= $this->renderAll($this->nodelist, $context); + + $index++; + + if (isset($context->registers['break'])) { + unset($context->registers['break']); + break; + } + if (isset($context->registers['continue'])) { + unset($context->registers['continue']); + } + } + + $context->pop(); + + return $result; + } + + private function renderDigit(Context $context) + { + $start = $this->start; + if (!is_integer($this->start)) { + $start = $context->get($this->start); + } + + $end = $this->collectionName; + if (!is_integer($this->collectionName)) { + $end = $context->get($this->collectionName); + } + + $range = [$start, $end]; + + $context->push(); + $result = ''; + $index = 0; + $length = $range[1] - $range[0]; + for ($i = $range[0]; $i <= $range[1]; $i++) { + $context->set($this->variableName, $i); + $context->set('forloop', [ + 'name' => $this->name, + 'length' => $length, + 'index' => $index + 1, + 'index0' => $index, + 'rindex' => $length - $index, + 'rindex0' => $length - $index - 1, + 'first' => (int)($index == 0), + 'last' => (int)($index == $length - 1), + ]); + + $result .= $this->renderAll($this->nodelist, $context); + + $index++; + + if (isset($context->registers['break'])) { + unset($context->registers['break']); + break; + } + if (isset($context->registers['continue'])) { + unset($context->registers['continue']); + } } $context->pop(); diff --git a/src/Liquid/Tag/TagIf.php b/src/Liquid/Tag/TagIf.php index 7bbbaeca..4624ae43 100644 --- a/src/Liquid/Tag/TagIf.php +++ b/src/Liquid/Tag/TagIf.php @@ -1,6 +1,6 @@ nodelist = & $this->nodelistHolders[count($this->blocks)]; - array_push($this->blocks, array('if', $markup, &$this->nodelist)); + array_push($this->blocks, ['if', $markup, &$this->nodelist]); parent::__construct($markup, $tokens, $fileSystem); } @@ -66,14 +67,14 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * @param array $params * @param array $tokens */ - public function unknownTag($tag, $params, array $tokens) { + public function unknownTag($tag, $params, array $tokens) + { if ($tag == 'else' || $tag == 'elsif') { // Update reference to nodelistHolder for this block $this->nodelist = & $this->nodelistHolders[count($this->blocks) + 1]; - $this->nodelistHolders[count($this->blocks) + 1] = array(); - - array_push($this->blocks, array($tag, $params, &$this->nodelist)); + $this->nodelistHolders[count($this->blocks) + 1] = []; + array_push($this->blocks, [$tag, $params, &$this->nodelist]); } else { parent::unknownTag($tag, $params, $tokens); } @@ -84,10 +85,11 @@ public function unknownTag($tag, $params, array $tokens) { * * @param Context $context * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\ParseException * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $context->push(); $logicalRegex = new Regexp('/\s+(and|or)\s+/'); @@ -110,7 +112,7 @@ public function render(Context $context) { // Extract individual conditions $temp = $logicalRegex->split($block[1]); - $conditions = array(); + $conditions = []; foreach ($temp as $condition) { if ($conditionalRegex->match($condition)) { @@ -118,13 +120,13 @@ public function render(Context $context) { $operator = (isset($conditionalRegex->matches[2])) ? $conditionalRegex->matches[2] : null; $right = (isset($conditionalRegex->matches[3])) ? $conditionalRegex->matches[3] : null; - array_push($conditions, array( + array_push($conditions, [ 'left' => $left, 'operator' => $operator, - 'right' => $right - )); + 'right' => $right, + ]); } else { - throw new LiquidException("Syntax Error in tag 'if' - Valid syntax: if [condition]"); + throw new ParseException("Syntax Error in tag 'if' - Valid syntax: if [condition]"); } } if (count($logicalOperators)) { @@ -137,12 +139,14 @@ public function render(Context $context) { $display = ($display || $this->interpretCondition($conditions[$k + 1]['left'], $conditions[$k + 1]['right'], $conditions[$k + 1]['operator'], $context)); } } - } else { // If statement is a single condition $display = $this->interpretCondition($conditions[0]['left'], $conditions[0]['right'], $conditions[0]['operator'], $context); } + // hook for unless tag + $display = $this->negateIfUnless($display); + if ($display) { $result = $this->renderAll($block[2], $context); @@ -155,4 +159,10 @@ public function render(Context $context) { return $result; } -} \ No newline at end of file + + protected function negateIfUnless($display) + { + // no need to negate a condition in a regular `if` tag (will do that in `unless` tag) + return $display; + } +} diff --git a/src/Liquid/Tag/TagIfchanged.php b/src/Liquid/Tag/TagIfchanged.php index 0ad50b47..42eb371d 100644 --- a/src/Liquid/Tag/TagIfchanged.php +++ b/src/Liquid/Tag/TagIfchanged.php @@ -1,6 +1,6 @@ lastValue == $output) { return ''; - } else { - $this->lastValue = $output; - return $this->lastValue; } - + $this->lastValue = $output; + return $this->lastValue; } } diff --git a/src/Liquid/Tag/TagInclude.php b/src/Liquid/Tag/TagInclude.php index 71320283..fe03d9b6 100644 --- a/src/Liquid/Tag/TagInclude.php +++ b/src/Liquid/Tag/TagInclude.php @@ -1,6 +1,6 @@ match($markup)) { - $this->templateName = substr($regex->matches[1], 1, strlen($regex->matches[1]) - 2); + if (!$regex->match($markup)) { + throw new ParseException("Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]"); + } - if (isset($regex->matches[1])) { - $this->collection = (isset($regex->matches[3])) ? ($regex->matches[3] == "for") : null; - $this->variable = (isset($regex->matches[4])) ? $regex->matches[4] : null; - } + $unquoted = (strpos($regex->matches[1], '"') === false && strpos($regex->matches[1], "'") === false); - $this->extractAttributes($markup); - } else { - throw new LiquidException("Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]"); + $start = 1; + $len = strlen($regex->matches[1]) - 2; + + if ($unquoted) { + $start = 0; + $len = strlen($regex->matches[1]); } + $this->templateName = substr($regex->matches[1], $start, $len); + + if (isset($regex->matches[1])) { + $this->collection = (isset($regex->matches[3])) ? ($regex->matches[3] == "for") : null; + $this->variable = (isset($regex->matches[4])) ? $regex->matches[4] : null; + } + + $this->extractAttributes($markup); + parent::__construct($markup, $tokens, $fileSystem); } @@ -98,48 +111,51 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @param array $tokens * - * @throws \Liquid\LiquidException + * @throws \Liquid\Exception\MissingFilesystemException */ - public function parse(array &$tokens) { + public function parse(array &$tokens) + { if ($this->fileSystem === null) { - throw new LiquidException("No file system"); + throw new MissingFilesystemException("No file system"); } // read the source of the template and create a new sub document $source = $this->fileSystem->readTemplateFile($this->templateName); - $this->hash = md5($source); - $cache = Template::getCache(); - if (isset($cache)) { - if (($this->document = $cache->read($this->hash)) != false && $this->document->checkIncludes() != true) { - } else { - $templateTokens = Template::tokenize($source); - $this->document = new Document($templateTokens, $this->fileSystem); - $cache->write($this->hash, $this->document); - } - } else { + if (!$cache) { + // tokens in this new document + $templateTokens = Template::tokenize($source); + $this->document = new Document($templateTokens, $this->fileSystem); + return; + } + + $this->hash = md5($source); + $this->document = $cache->read($this->hash); + + if ($this->document == false || $this->document->hasIncludes() == true) { $templateTokens = Template::tokenize($source); $this->document = new Document($templateTokens, $this->fileSystem); + $cache->write($this->hash, $this->document); } } /** - * check for cached includes + * Check for cached includes; if there are - do not use cache * + * @see Document::hasIncludes() * @return boolean */ - public function checkIncludes() { - $cache = Template::getCache(); - - if ($this->document->checkIncludes() == true) { + public function hasIncludes() + { + if ($this->document->hasIncludes() == true) { return true; } $source = $this->fileSystem->readTemplateFile($this->templateName); - if ($cache->exists(md5($source)) && $this->hash == md5($source)) { + if (Template::getCache()->exists(md5($source)) && $this->hash === md5($source)) { return false; } @@ -153,7 +169,8 @@ public function checkIncludes() { * * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $result = ''; $variable = $context->get($this->variable); diff --git a/src/Liquid/Tag/TagIncrement.php b/src/Liquid/Tag/TagIncrement.php index e60ff0f6..dcbb3845 100644 --- a/src/Liquid/Tag/TagIncrement.php +++ b/src/Liquid/Tag/TagIncrement.php @@ -1,6 +1,6 @@ match($markup)) { $this->toIncrement = $syntax->matches[0]; } else { - throw new LiquidException("Syntax Error in 'increment' - Valid syntax: increment [var]"); + throw new ParseException("Syntax Error in 'increment' - Valid syntax: increment [var]"); } } @@ -62,7 +65,8 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * * @return string|void */ - public function render(Context $context) { + public function render(Context $context) + { // If the value is not set in the environment check to see if it // exists in the context, and if not set it to -1 if (!isset($context->environments[0][$this->toIncrement])) { diff --git a/src/Liquid/Tag/TagPaginate.php b/src/Liquid/Tag/TagPaginate.php index 1ab81884..3d1fc3cf 100644 --- a/src/Liquid/Tag/TagPaginate.php +++ b/src/Liquid/Tag/TagPaginate.php @@ -1,6 +1,6 @@ - * {% endfor %} - * {% endpaginate %} + * {% paginate collection.products by 5 %} + * {% for product in collection.products %} + * + * {% endfor %} + * {% endpaginate %} * */ class TagPaginate extends AbstractBlock { /** - * @var array The collection to paginate - */ - private $collectionName; - - /** - * @var array The collection object - */ - private $collection; - - /** - * - * @var int The size of the collection - */ - private $collectionSize; + * @var array The collection to paginate + */ + private $collectionName; + + /** + * @var array The collection object + */ + private $collection; + + /** + * @var int The size of the collection + */ + private $collectionSize; + + /** + * @var int The number of items to paginate by + */ + private $numberItems; + + /** + * @var int The current page + */ + private $currentPage; + + /** + * @var int The current offset (no of pages times no of items) + */ + private $currentOffset; + + /** + * @var int Total pages + */ + private $totalPages; + + + /** + * Constructor + * + * @param string $markup + * @param array $tokens + * @param FileSystem|null $fileSystem + * + * @throws \Liquid\Exception\ParseException + * + */ + public function __construct($markup, array &$tokens, ?FileSystem $fileSystem = null) + { + parent::__construct($markup, $tokens, $fileSystem); + + $syntax = new Regexp('/(' . Liquid::get('VARIABLE_NAME') . ')\s+by\s+(\w+)/'); + + if ($syntax->match($markup)) { + $this->collectionName = $syntax->matches[1]; + $this->numberItems = $syntax->matches[2]; + $this->extractAttributes($markup); + } else { + throw new ParseException("Syntax Error - Valid syntax: paginate [collection] by [items]"); + } + } + + /** + * Renders the tag + * + * @param Context $context + * + * @return string + * + */ + public function render(Context $context) + { + $this->collection = $context->get($this->collectionName); + + if ($this->collection instanceof \Traversable) { + $this->collection = iterator_to_array($this->collection); + } + + if (!is_array($this->collection)) { + // TODO do not throw up if error mode allows, see #83 + throw new RenderException("Missing collection with name '{$this->collectionName}'"); + } + + // How many pages are there? + $this->collectionSize = count($this->collection); + $this->totalPages = ceil($this->collectionSize / $this->numberItems); + + // Whatever there is in the context, we need a number + $this->currentPage = intval($context->get(Liquid::get('PAGINATION_CONTEXT_KEY'))); + + // Page number can only be between 1 and a number of pages + $this->currentPage = max(1, min($this->currentPage, $this->totalPages)); + + // Find the offset and select that part + $this->currentOffset = ($this->currentPage - 1) * $this->numberItems; + $paginatedCollection = array_slice($this->collection, $this->currentOffset, $this->numberItems); + + // We must work in a new scope so we won't pollute a global scope + $context->push(); + + // Sets the collection if it's a key of another collection (ie search.results, collection.products, blog.articles) + $segments = explode('.', $this->collectionName); + if (count($segments) == 2) { + $context->set($segments[0], [$segments[1] => $paginatedCollection]); + } else { + $context->set($this->collectionName, $paginatedCollection); + } + + $paginate = [ + 'page_size' => $this->numberItems, + 'current_page' => $this->currentPage, + 'current_offset' => $this->currentOffset, + 'pages' => $this->totalPages, + 'items' => $this->collectionSize, + ]; + + // Get the name of the request field to use in URLs + $pageRequestKey = Liquid::get('PAGINATION_REQUEST_KEY'); + + if ($this->currentPage > 1) { + $paginate['previous']['title'] = 'Previous'; + $paginate['previous']['url'] = $this->currentUrl($context, [ + $pageRequestKey => $this->currentPage - 1, + ]); + } + + if ($this->currentPage < $this->totalPages) { + $paginate['next']['title'] = 'Next'; + $paginate['next']['url'] = $this->currentUrl($context, [ + $pageRequestKey => $this->currentPage + 1, + ]); + } + + $context->set('paginate', $paginate); + + $result = parent::render($context); + + $context->pop(); + + return $result; + } /** - * @var int The number of items to paginate by - */ - private $numberItems; - - /** - * @var int The current page - */ - private $currentPage; - - /** - * @var int The current offset (no of pages times no of items) - */ - private $currentOffset; - - /** - * @var int Total pages - */ - private $totalPages; - - - /** - * Constructor - * - * @param string $markup - * @param array $tokens - * @param FileSystem $fileSystem - * - * @throws \Liquid\LiquidException - * - */ - public function __construct($markup, array &$tokens, FileSystem $fileSystem = null) { - - parent::__construct($markup, $tokens, $fileSystem); - - $syntax = new Regexp('/(' . Liquid::get('ALLOWED_VARIABLE_CHARS') . '+)\s+by\s+(\w+)/'); - - if ($syntax->match($markup)) { - $this->collectionName = $syntax->matches[1]; - $this->numberItems = $syntax->matches[2]; - $this->extractAttributes($markup); - } else { - throw new LiquidException("Syntax Error - Valid syntax: paginate [collection] by [items]"); - } - - } - - /** - * Renders the tag - * - * @param Context $context - * - * @return string - * - */ - public function render(Context $context) { - - $this->currentPage = ( is_numeric($context->get('page')) ) ? $context->get('page') : 1; - $this->currentOffset = ($this->currentPage - 1) * $this->numberItems; - $this->collection = $context->get($this->collectionName); - $this->collectionSize = count($this->collection); - $this->totalPages = ceil($this->collectionSize / $this->numberItems); - $paginatedCollection = array_slice($this->collection, $this->currentOffset, $this->numberItems); - - // Sets the collection if it's a key of another collection (ie search.results, collection.products, blog.articles) - $segments = explode('.', $this->collectionName); - if (count($segments) == 2) { - $context->set($segments[0], array($segments[1] => $paginatedCollection)); - } else { - $context->set($this->collectionName, $paginatedCollection); - } - - $paginate = array( - 'page_size' => $this->numberItems, - 'current_page' => $this->currentPage, - 'current_offset' => $this->currentOffset, - 'pages' => $this->totalPages, - 'items' => $this->collectionSize - ); - - if ( $this->currentPage != 1 ) { - $paginate['previous']['title'] = 'Previous'; - $paginate['previous']['url'] = $this->currentUrl($context) . '?page=' . ($this->currentPage - 1); - - } - - if ( $this->currentPage != $this->totalPages ) { - $paginate['next']['title'] = 'Next'; - $paginate['next']['url'] = $this->currentUrl($context) . '?page=' . ($this->currentPage + 1); - } - - $context->set('paginate', $paginate); - - return parent::render($context); - - } - - /** - * Returns the current page URL - * - * @param Context $context - * - * @return string - * - */ - public function currentUrl($context) { - - $uri = explode('?', $context->get('REQUEST_URI')); - - $url = 'http'; - if ($context->get('HTTPS') == 'on') $url .= 's'; - $url .= '://' . $context->get('HTTP_HOST') . reset($uri); - - return $url; - - } - + * Returns the current page URL + * + * @param Context $context + * @param array $queryPart + * + * @return string + * + */ + public function currentUrl($context, $queryPart = []) + { + // From here we have $url->path and $url->query + $url = (object) parse_url($context->get('REQUEST_URI') ?: ''); + + // Let's merge the query part + if (isset($url->query)) { + parse_str($url->query, $url->query); + $url->query = array_merge($url->query, $queryPart); + } else { + $url->query = $queryPart; + } + + $url->query = http_build_query($url->query); + + $scheme = $context->get('HTTPS') == 'on' ? 'https' : 'http'; + + return "$scheme://{$context->get('HTTP_HOST')}{$url->path}?{$url->query}"; + } } diff --git a/src/Liquid/Tag/TagRaw.php b/src/Liquid/Tag/TagRaw.php index d5acf00c..922fdea8 100644 --- a/src/Liquid/Tag/TagRaw.php +++ b/src/Liquid/Tag/TagRaw.php @@ -1,6 +1,6 @@ nodelist = array(); - - if (!is_array($tokens)) { - return; - } + $this->nodelist = []; - while (count($tokens)) { - $token = array_shift($tokens); + for ($i = 0, $n = count($tokens); $i < $n; $i++) { + if ($tokens[$i] === null) { + continue; + } + $token = $tokens[$i]; + $tokens[$i] = null; if ($tagRegexp->match($token)) { - // If we found the proper block delimitor just end parsing here and let the outer block proceed + // If we found the proper block delimiter just end parsing here and let the outer block proceed if ($tagRegexp->matches[1] == $this->blockDelimiter()) { - return; + break; } } diff --git a/src/Liquid/Tag/TagTablerow.php b/src/Liquid/Tag/TagTablerow.php index 8f6ba435..ca9dfc0d 100644 --- a/src/Liquid/Tag/TagTablerow.php +++ b/src/Liquid/Tag/TagTablerow.php @@ -1,6 +1,6 @@ match($markup)) { $this->variableName = $syntax->matches[1]; @@ -64,7 +66,7 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu $this->extractAttributes($markup); } else { - throw new LiquidException("Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"); + throw new ParseException("Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols:3"); } } @@ -72,14 +74,19 @@ public function __construct($markup, array &$tokens, FileSystem $fileSystem = nu * Renders the current node * * @param Context $context - * + * @throws \Liquid\Exception\RenderException * @return string */ - public function render(Context $context) { + public function render(Context $context) + { $collection = $context->get($this->collectionName); + if ($collection instanceof \Traversable) { + $collection = iterator_to_array($collection); + } + if (!is_array($collection)) { - die('not array, ' . var_export($collection, true)); + throw new RenderException("Not an array"); } // discard keys @@ -93,7 +100,7 @@ public function render(Context $context) { $length = count($collection); - $cols = $context->get($this->attributes['cols']); + $cols = isset($this->attributes['cols']) ? $context->get($this->attributes['cols']) : PHP_INT_MAX; $row = 1; $col = 0; @@ -104,21 +111,35 @@ public function render(Context $context) { foreach ($collection as $index => $item) { $context->set($this->variableName, $item); - $context->set('tablerowloop', array( + $context->set('tablerowloop', [ 'length' => $length, 'index' => $index + 1, 'index0' => $index, 'rindex' => $length - $index, 'rindex0' => $length - $index - 1, 'first' => (int)($index == 0), - 'last' => (int)($index == $length - 1) - )); + 'last' => (int)($index == $length - 1), + ]); - $result .= "" . $this->renderAll($this->nodelist, $context) . ""; + $text = $this->renderAll($this->nodelist, $context); + $break = isset($context->registers['break']); + $continue = isset($context->registers['continue']); + + if ((!$break && !$continue) || strlen(trim($text)) > 0) { + $result .= "$text"; + } if ($col == $cols && !($index == $length - 1)) { $col = 0; - $result .= "\n"; + $result .= "\n\n"; + } + + if ($break) { + unset($context->registers['break']); + break; + } + if ($continue) { + unset($context->registers['continue']); } } diff --git a/src/Liquid/Tag/TagUnless.php b/src/Liquid/Tag/TagUnless.php index f0cc6f56..7d1f94dd 100644 --- a/src/Liquid/Tag/TagUnless.php +++ b/src/Liquid/Tag/TagUnless.php @@ -1,6 +1,6 @@ value array) - * @param string $subject - * @return string - */ - protected function strReplaceOne($replacer, $subject) { - $res = $subject; - foreach($replacer as $from => $to) { - $res = str_ireplace($from, $to, $subject, $count); - if ($count > 0) { - break; - } - } - return $res; - } - - /** - * Method revert operators in string - * before - * a == 1 and b == 2 - * after - * a != 1 or b != 2 - */ - protected function revertOperators() { - - // replace - $replacerOperators = array( - '==' => '!=', - '<=' => '>', - '>=' => '<', - '>' => '<=', - '<' => '>=', - '!=' => '==' - ); - - $replacerLogicalOperators = array( - 'or' => 'and', - 'and' => 'or' - ); - - if (count($this->blocks) > 0) { - if (count($this->blocks[0]) > 1) { - $condition = $this->blocks[0][1]; - - $condition = $this->strReplaceOne($replacerOperators, $condition); - $condition = $this->strReplaceOne($replacerLogicalOperators, $condition); - - // if no operators was changed, then it means there is no operators - // soo make condition ==false - if ($this->blocks[0][1] === $condition) { - $condition .= '== false'; - } - $this->blocks[0][1] = $condition; - } - } +class TagUnless extends TagIf +{ + protected function negateIfUnless($display) + { + return !$display; } - - /** - * Render the tag - * - * @param Context $context - * - * @throws \Liquid\LiquidException - * @return string - */ - public function render(Context $context) { - $this->revertOperators(); - $res = parent::render($context); - return $res; - } - -} \ No newline at end of file +} diff --git a/src/Liquid/Template.php b/src/Liquid/Template.php index 82ec0d3b..1fe68147 100644 --- a/src/Liquid/Template.php +++ b/src/Liquid/Template.php @@ -1,6 +1,6 @@ fileSystem = $path !== null ? new LocalFileSystem($path) : null; @@ -66,39 +77,48 @@ public function __construct($path = null, $cache = null) { /** * @param FileSystem $fileSystem */ - public function setFileSystem(FileSystem $fileSystem) { + public function setFileSystem(FileSystem $fileSystem) + { $this->fileSystem = $fileSystem; } /** - * @param array|Cache $cache + * @param array|Cache|null $cache * - * @throws LiquidException + * @throws \Liquid\Exception\CacheException */ - public function setCache($cache) { + public static function setCache($cache) + { if (is_array($cache)) { - if (isset($cache['cache']) && class_exists('\Liquid\Cache\\' . ucwords($cache['cache']))) { - $classname = '\Liquid\Cache\\' . ucwords($cache['cache']); + if (isset($cache['cache']) && class_exists($classname = self::CLASS_PREFIX . ucwords($cache['cache']))) { self::$cache = new $classname($cache); } else { - throw new LiquidException('Invalid cache options!'); + throw new CacheException('Invalid cache options!'); } - } else if ($cache instanceof Cache) { + } + + if ($cache instanceof Cache) { self::$cache = $cache; } + + if (is_null($cache)) { + self::$cache = null; + } } /** * @return Cache */ - public static function getCache() { + public static function getCache() + { return self::$cache; } /** * @return Document */ - public function getRoot() { + public function getRoot() + { return $this->root; } @@ -108,14 +128,16 @@ public function getRoot() { * @param string $name * @param string $class */ - public function registerTag($name, $class) { + public static function registerTag($name, $class) + { self::$tags[$name] = $class; } /** * @return array */ - public static function getTags() { + public static function getTags() + { return self::$tags; } @@ -124,8 +146,19 @@ public static function getTags() { * * @param string $filter */ - public function registerFilter($filter) { - $this->filters[] = $filter; + public function registerFilter($filter, ?callable $callback = null) + { + // Store callback for later use + if ($callback) { + $this->filters[] = [$filter, $callback]; + } else { + $this->filters[] = $filter; + } + } + + public function setTickFunction(callable $tickFunction) + { + $this->tickFunction = $tickFunction; } /** @@ -135,10 +168,11 @@ public function registerFilter($filter) { * * @return array */ - public static function tokenize($source) { + public static function tokenize($source) + { return empty($source) - ? array() - : preg_split(Liquid::get('TOKENIZATION_REGEXP'), $source, null, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); + ? [] + : preg_split(Liquid::get('TOKENIZATION_REGEXP'), $source, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); } /** @@ -148,22 +182,55 @@ public static function tokenize($source) { * * @return Template */ - public function parse($source) { - if (self::$cache !== null) { - if (($this->root = self::$cache->read(md5($source))) != false && $this->root->checkIncludes() != true) { - } else { - $tokens = Template::tokenize($source); - $this->root = new Document($tokens, $this->fileSystem); - self::$cache->write(md5($source), $this->root); - } - } else { - $tokens = Template::tokenize($source); - $this->root = new Document($tokens, $this->fileSystem); + public function parse($source) + { + if (!self::$cache) { + return $this->parseAlways($source); + } + + $hash = md5($source); + $this->root = self::$cache->read($hash); + + // if no cached version exists, or if it checks for includes + if ($this->root == false || $this->root->hasIncludes() == true) { + $this->parseAlways($source); + self::$cache->write($hash, $this->root); } return $this; } + /** + * Parses the given source string regardless of caching + * + * @param string $source + * + * @return Template + */ + private function parseAlways($source) + { + $tokens = Template::tokenize($source); + $this->root = new Document($tokens, $this->fileSystem); + + return $this; + } + + /** + * Parses the given template file + * + * @param string $templatePath + * @throws \Liquid\Exception\MissingFilesystemException + * @return Template + */ + public function parseFile($templatePath) + { + if (!$this->fileSystem) { + throw new MissingFilesystemException("Could not load a template without an initialized file system"); + } + + return $this->parse($this->fileSystem->readTemplateFile($templatePath)); + } + /** * Renders the current template * @@ -173,9 +240,14 @@ public function parse($source) { * * @return string */ - public function render(array $assigns = array(), $filters = null, array $registers = array()) { + public function render(array $assigns = [], $filters = null, array $registers = []) + { $context = new Context($assigns, $registers); + if ($this->tickFunction) { + $context->setTickFunction($this->tickFunction); + } + if (!is_null($filters)) { if (is_array($filters)) { $this->filters = array_merge($this->filters, $filters); @@ -185,7 +257,12 @@ public function render(array $assigns = array(), $filters = null, array $registe } foreach ($this->filters as $filter) { - $context->addFilters($filter); + if (is_array($filter)) { + // Unpack a callback saved as second argument + $context->addFilters(...$filter); + } else { + $context->addFilters($filter); + } } return $this->root->render($context); diff --git a/src/Liquid/Variable.php b/src/Liquid/Variable.php index de77fb3c..c168fde2 100644 --- a/src/Liquid/Variable.php +++ b/src/Liquid/Variable.php @@ -1,6 +1,6 @@ markup = $markup; - $quotedFragmentRegexp = new Regexp('/\s*(' . Liquid::get('QUOTED_FRAGMENT') . ')/'); - $filterSeperatorRegexp = new Regexp('/' . Liquid::get('FILTER_SEPARATOR') . '\s*(.*)/'); - $filterSplitRegexp = new Regexp('/' . Liquid::get('FILTER_SEPARATOR') . '/'); - $filterNameRegexp = new Regexp('/\s*(\w+)/'); - $filterArgumentRegexp = new Regexp('/(?:' . Liquid::get('FILTER_ARGUMENT_SEPARATOR') . '|' . Liquid::get('ARGUMENT_SEPARATOR') . ')\s*(' . Liquid::get('QUOTED_FRAGMENT_FILTER_ARGUMENT') . ')/'); - - $quotedFragmentRegexp->match($markup); - - $this->name = (isset($quotedFragmentRegexp->matches[1])) ? $quotedFragmentRegexp->matches[1] : null; - - if ($filterSeperatorRegexp->match($markup)) { - $filters = $filterSplitRegexp->split($filterSeperatorRegexp->matches[1]); - - foreach ($filters as $filter) { - $filterNameRegexp->match($filter); - $filtername = $filterNameRegexp->matches[1]; - - $filterArgumentRegexp->matchAll($filter); - $matches = Liquid::arrayFlatten($filterArgumentRegexp->matches[1]); - - $this->filters[] = array($filtername, $matches); + $filterSep = new Regexp('/' . Liquid::get('FILTER_SEPARATOR') . '\s*(.*)/m'); + $syntaxParser = new Regexp('/(' . Liquid::get('QUOTED_FRAGMENT') . ')(.*)/ms'); + $filterParser = new Regexp('/(?:\s+|' . Liquid::get('QUOTED_FRAGMENT') . '|' . Liquid::get('ARGUMENT_SEPARATOR') . ')+/'); + $filterArgsRegex = new Regexp('/(?:' . Liquid::get('FILTER_ARGUMENT_SEPARATOR') . '|' . Liquid::get('ARGUMENT_SEPARATOR') . ')\s*((?:\w+\s*\:\s*)?' . Liquid::get('QUOTED_FRAGMENT') . ')/'); + + $this->filters = []; + if ($syntaxParser->match($markup)) { + $nameMarkup = $syntaxParser->matches[1]; + $this->name = $nameMarkup; + $filterMarkup = $syntaxParser->matches[2]; + + if ($filterSep->match($filterMarkup)) { + $filterParser->matchAll($filterSep->matches[1]); + + foreach ($filterParser->matches[0] as $filter) { + $filter = trim($filter); + if (preg_match('/\w+/', $filter, $matches)) { + $filterName = $matches[0]; + $filterArgsRegex->matchAll($filter); + $matches = Liquid::arrayFlatten($filterArgsRegex->matches[1]); + $this->filters[] = $this->parseFilterExpressions($filterName, $matches); + } + } } - - } else { - $this->filters = array(); } if (Liquid::get('ESCAPE_BY_DEFAULT')) { @@ -78,7 +78,7 @@ public function __construct($markup) { foreach ($this->filters as $filter) { // with empty filters set we would just move along - if (in_array($filter[0], array('escape', 'escape_once', 'raw', 'newline_to_br'))) { + if (in_array($filter[0], ['escape', 'escape_once', 'raw', 'newline_to_br'])) { // if we have any raw-like filter, stop $addEscapeFilter = false; break; @@ -86,9 +86,36 @@ public function __construct($markup) { } if ($addEscapeFilter) { - $this->filters[] = array('escape', array()); + $this->filters[] = ['escape', []]; + } + } + } + + /** + * @param string $filterName + * @param array $unparsedArgs + * @return array + */ + private static function parseFilterExpressions($filterName, array $unparsedArgs) + { + $filterArgs = []; + $keywordArgs = []; + + $justTagAttributes = new Regexp('/\A' . trim(Liquid::get('TAG_ATTRIBUTES'), '/') . '\z/'); + + foreach ($unparsedArgs as $a) { + if ($justTagAttributes->match($a)) { + $keywordArgs[$justTagAttributes->matches[1]] = $justTagAttributes->matches[2]; + } else { + $filterArgs[] = $a; } } + + if (count($keywordArgs)) { + $filterArgs[] = $keywordArgs; + } + + return [$filterName, $filterArgs]; } /** @@ -96,7 +123,8 @@ public function __construct($markup) { * * @return string The name of the variable */ - public function getName() { + public function getName() + { return $this->name; } @@ -105,7 +133,8 @@ public function getName() { * * @return array */ - public function getFilters() { + public function getFilters() + { return $this->filters; } @@ -116,21 +145,29 @@ public function getFilters() { * * @return mixed|string */ - public function render(Context $context) { + public function render(Context $context) + { $output = $context->get($this->name); - foreach ($this->filters as $filter) { list($filtername, $filterArgKeys) = $filter; - $filterArgValues = array(); + $filterArgValues = []; + $keywordArgValues = []; foreach ($filterArgKeys as $arg_key) { - $filterArgValues[] = $context->get($arg_key); + if (is_array($arg_key)) { + foreach ($arg_key as $keywordArgName => $keywordArgKey) { + $keywordArgValues[$keywordArgName] = $context->get($keywordArgKey); + } + + $filterArgValues[] = $keywordArgValues; + } else { + $filterArgValues[] = $context->get($arg_key); + } } $output = $context->invoke($filtername, $output, $filterArgValues); } - return $output; } } diff --git a/tests/Liquid/AbstractBlockTest.php b/tests/Liquid/AbstractBlockTest.php new file mode 100644 index 00000000..fef3ac66 --- /dev/null +++ b/tests/Liquid/AbstractBlockTest.php @@ -0,0 +1,38 @@ +expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{% block }'); + } + + public function testWhitespaceHandler() + { + $this->assertTemplateResult('foo', '{% if true %}foo{% endif %}'); + $this->assertTemplateResult(' foo ', '{% if true %} foo {% endif %}'); + $this->assertTemplateResult(' foo ', ' {% if true %} foo {% endif %} '); + $this->assertTemplateResult('foo ', '{% if true -%} foo {% endif %}'); + $this->assertTemplateResult('foo', '{% if true -%} foo {%- endif %}'); + $this->assertTemplateResult('foo', ' {%- if true -%} foo {%- endif %}'); + $this->assertTemplateResult('foo', ' {%- if true -%} foo {%- endif -%} '); + $this->assertTemplateResult('foo', ' {%- if true -%} foo {%- endif -%} {%- if false -%} bar {%- endif -%} '); + $this->assertTemplateResult('foobar', ' {%- if true -%} foo {%- endif -%} {%- if true -%} bar {%- endif -%} '); + $this->assertTemplateResult('-> foo', '{% if true %}-> {% endif %} {%- if true -%} foo {%- endif -%}'); + } +} diff --git a/tests/Liquid/Cache/ApcTest.php b/tests/Liquid/Cache/ApcTest.php new file mode 100644 index 00000000..2b66116d --- /dev/null +++ b/tests/Liquid/Cache/ApcTest.php @@ -0,0 +1,53 @@ +markTestSkipped("Alternative PHP Cache (APC) not available"); + } + + if (!ini_get('apc.enable_cli')) { + $this->markTestSkipped("APC not enabled with cli. Run with: php -d apc.enable_cli=1"); + } + + $this->cache = new Apc(); + } + + public function testNotExists() + { + $this->assertFalse($this->cache->exists('no_such_key')); + } + + public function testReadNotExisting() + { + $this->assertFalse($this->cache->read('no_such_key')); + } + + public function testSetGetFlush() + { + $this->assertTrue($this->cache->write('test', 'example'), "Failed to set value."); + $this->assertSame('example', $this->cache->read('test')); + $this->assertTrue($this->cache->flush()); + $this->assertFalse($this->cache->read('test')); + } +} diff --git a/tests/Liquid/Cache/FileTest.php b/tests/Liquid/Cache/FileTest.php index 4b8a3a64..88f55d85 100644 --- a/tests/Liquid/Cache/FileTest.php +++ b/tests/Liquid/Cache/FileTest.php @@ -1,6 +1,6 @@ cacheDir = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'cache_dir'; - $this->cache = new File(array('cache_dir' => $this->cacheDir)); + + // Remove tmp cache files because they may remain after a failed test run + $this->removeOldCachedFiles(); + + $this->cache = new File([ + 'cache_dir' => $this->cacheDir, + 'cache_expire' => 3600, + 'cache_prefix' => 'liquid_', + ]); } - protected function tearDown() { + protected function tearDown(): void + { parent::tearDown(); - // Remove tmp cache files - array_map('unlink', glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); + $this->removeOldCachedFiles(); } - /** - * @expectedException \Liquid\LiquidException - */ - public function testConstructInvalidOptions() { + private function removeOldCachedFiles(): void + { + if ($files = glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')) { + array_map('unlink', $files); + } + } + + public function testConstructInvalidOptions() + { + $this->expectException(\Liquid\Exception\FilesystemException::class); + new File(); } - /** - * @expectedException \Liquid\LiquidException - */ - public function testConstructNoSuchDirOrNotWritable() { - new File(array('cache_dir' => '/no/such/dir/liquid/cache')); + public function testConstructNoSuchDirOrNotWritable() + { + $this->expectException(\Liquid\Exception\FilesystemException::class); + + new File(['cache_dir' => '/no/such/dir/liquid/cache']); } - public function testGetExistsNoFile() { + public function testGetExistsNoFile() + { $this->assertFalse($this->cache->exists('no_key')); } - public function testGetExistsExpired() { + public function testGetExistsExpired() + { $key = 'test'; $cacheFile = $this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_' . $key; touch($cacheFile, time() - 1000000); // long ago $this->assertFalse($this->cache->exists($key)); } - public function testGetExistsNotExpired() { + public function testGetExistsNotExpired() + { $key = 'test'; $cacheFile = $this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_' . $key; touch($cacheFile); $this->assertTrue($this->cache->exists($key)); } - public function testFlushAll() { + public function testFlushAll() + { touch($this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_test'); touch($this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_test_two'); - $this->assertCount(2, glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); + $this->assertGreaterThanOrEqual(2, count(glob($this->cacheDir . DIRECTORY_SEPARATOR . '*'))); $this->cache->flush(); $this->assertCount(0, glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); } - public function testFlushExpired() { + public function testFlushExpired() + { touch($this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_test'); touch($this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_test_two', time() - 1000000); - $this->assertCount(2, glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); + $files = join(', ', glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); + + $this->assertGreaterThanOrEqual(2, count(glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')), "Found more than two files: $files"); $this->cache->flush(true); $this->assertCount(1, glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); } - public function testWriteNoSerialize() { + public function testWriteNoSerialize() + { $key = 'test'; $value = 'test_value'; @@ -96,7 +120,8 @@ public function testWriteNoSerialize() { $this->assertEquals($value, file_get_contents($this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_' . $key)); } - public function testWriteSerialized() { + public function testWriteSerialized() + { $key = 'test'; $value = 'test_value'; @@ -105,7 +130,11 @@ public function testWriteSerialized() { $this->assertEquals(serialize($value), file_get_contents($this->cacheDir . DIRECTORY_SEPARATOR . 'liquid_' . $key)); } - public function testWriteGc() { + /** + * @depends testWriteSerialized + */ + public function testWriteGc() + { $key = 'test'; $value = 'test_value'; @@ -117,11 +146,13 @@ public function testWriteGc() { $this->assertCount(1, glob($this->cacheDir . DIRECTORY_SEPARATOR . '*')); } - public function testReadNonExisting() { + public function testReadNonExisting() + { $this->assertFalse($this->cache->read('no_such_key')); } - public function testReadNoUnserialize() { + public function testReadNoUnserialize() + { $key = 'test'; $value = 'test_value'; @@ -130,7 +161,8 @@ public function testReadNoUnserialize() { $this->assertSame($value, $this->cache->read($key, false)); } - public function testReadSerialize() { + public function testReadSerialize() + { $key = 'test'; $value = 'test_value'; diff --git a/tests/Liquid/Cache/LocalTest.php b/tests/Liquid/Cache/LocalTest.php new file mode 100644 index 00000000..a72cbf90 --- /dev/null +++ b/tests/Liquid/Cache/LocalTest.php @@ -0,0 +1,45 @@ +cache = new Local(); + } + + public function testNotExists() + { + $this->assertFalse($this->cache->exists('no_such_key')); + } + + public function testReadNotExisting() + { + $this->assertFalse($this->cache->read('no_such_key')); + } + + public function testSetGetFlush() + { + $this->assertTrue($this->cache->write('test', 'example')); + $this->assertSame('example', $this->cache->read('test')); + $this->assertTrue($this->cache->flush()); + $this->assertFalse($this->cache->read('test')); + } +} diff --git a/tests/Liquid/ContextTest.php b/tests/Liquid/ContextTest.php index 9923a76a..a9d7137f 100644 --- a/tests/Liquid/ContextTest.php +++ b/tests/Liquid/ContextTest.php @@ -1,6 +1,6 @@ value; + } +} + +class NestedObject +{ + public $property; + public $value = -1; + + public function toLiquid() + { + // we intentionally made the value different so + // that we could see where it is coming from + return [ + 'property' => $this->property, + 'value' => 42, + ]; + } +} + +class CountableObject implements \Countable +{ + public function count(): int + { + return 2; + } +} + +class ToArrayObject +{ + public $property; + public $value = -1; + + public function toArray() + { + // we intentionally made the value different so + // that we could see where it is coming from + return [ + 'property' => $this->property, + 'value' => 42, + ]; + } +} + +class GetSetObject +{ + public function field_exists($name) + { + return $name == 'answer'; + } + + public function get($prop) + { + if ($prop == 'answer') { + return 42; + } + } +} + +class GetSetMagic +{ + public function __get($prop) + { + if ($prop == 'prime') { + return 2; + } + } +} + + class HiFilter { - public function hi($value) { + public Context $context; + + public function hi($value) + { return $value . ' hi!'; } } class GlobalFilter { - public function notice($value) { + public Context $context; + + public function notice($value) + { return "Global $value"; } } class LocalFilter { - public function notice($value) { + public Context $context; + + public function notice($value) + { return "Local $value"; } } @@ -61,42 +166,49 @@ public function notice($value) { class ContextTest extends TestCase { /** @var Context */ - var $context; + public $context; - public function setup() { + protected function setUp(): void + { parent::setUp(); $this->context = new Context(); } - public function testScoping() { + public function testScoping() + { $this->context->push(); $this->assertNull($this->context->pop()); } /** - * @expectedException \Liquid\LiquidException */ - public function testNoScopeToPop() { + public function testNoScopeToPop() + { + $this->expectException(\Liquid\LiquidException::class); + $this->context->pop(); } /** - * @expectedException \Liquid\LiquidException */ - public function testGetArray() { - $this->context->get(array()); + public function testGetArray() + { + $this->expectException(\Liquid\LiquidException::class); + + $this->context->get([]); } - public function testGetNotVariable() { - $data = array( + public function testGetNotVariable() + { + $data = [ null => null, 'null' => null, 'true' => true, 'false' => false, "'quoted_string'" => 'quoted_string', '"double_quoted_string"' => "double_quoted_string", - ); + ]; foreach ($data as $key => $expected) { $this->assertEquals($expected, $this->context->get($key)); @@ -105,27 +217,82 @@ public function testGetNotVariable() { $this->assertEquals(42.00, $this->context->get(42.00)); } - public function testVariablesNotExisting() { + public function testVariablesNotExisting() + { $this->assertNull($this->context->get('test')); } - public function testVariableIsObjectWithNoToLiquid() { + public function testVariableIsObjectWithNoToLiquid() + { $this->context->set('test', new NoToLiquid()); $this->assertEquals(42, $this->context->get('test.answer')); $this->assertEquals(1, $this->context->get('test.count')); + $this->assertNull($this->context->get('test.invalid')); $this->assertEquals("forty two", $this->context->get('test')); + $this->assertEquals("example", $this->context->get('test.name')); } - /** - * @expectedException \Liquid\LiquidException - */ - public function testFinalVariableIsObject() { - $this->context->set('test', (object) array('value' => (object) array())); - $this->context->get('test.value'); + public function testToLiquidNull() + { + $object = new ToLiquidWrapper(); + $this->context->set('object', $object); + $this->assertNull($this->context->get('object.key')); + } + + public function testToLiquidStringKeyMustBeNull() + { + $object = new ToLiquidWrapper(); + $object->value = 'foo'; + $this->context->set('object', $object); + $this->assertNull($this->context->get('object.foo')); + $this->assertNull($this->context->get('object.foo.bar')); } - public function testVariables() { + public function testNestedObject() + { + $object = new NestedObject(); + $object->property = new NestedObject(); + $this->context->set('object', $object); + $this->assertEquals(42, $this->context->get('object.value')); + $this->assertEquals(42, $this->context->get('object.property.value')); + $this->assertNull($this->context->get('object.property.value.invalid')); + } + + public function testToArrayObject() + { + $object = new ToArrayObject(); + $object->property = new ToArrayObject(); + $this->context->set('object', $object); + $this->assertEquals(42, $this->context->get('object.value')); + $this->assertEquals(42, $this->context->get('object.property.value')); + $this->assertNull($this->context->get('object.property.value.invalid')); + } + + public function testGetSetObject() + { + $this->context->set('object', new GetSetObject()); + $this->assertEquals(42, $this->context->get('object.answer')); + $this->assertNull($this->context->get('object.invalid')); + } + + public function testGetSetMagic() + { + $this->context->set('object', new GetSetMagic()); + $this->assertEquals(2, $this->context->get('object.prime')); + $this->assertNull($this->context->get('object.invalid')); + } + + public function testFinalVariableCanBeObject() + { + $this->context->set('test', (object) ['value' => (object) []]); + $this->assertInstanceOf(\stdClass::class, $this->context->get('test.value')); + } + + public function testVariables() + { $this->context->set('test', 'test'); + $this->assertTrue($this->context->hasKey('test')); + $this->assertFalse($this->context->hasKey('test.foo')); $this->assertEquals('test', $this->context->get('test')); // We add this text to make sure we can return values that evaluate to false properly @@ -133,27 +300,77 @@ public function testVariables() { $this->assertEquals('0', $this->context->get('test_0')); } - public function testLengthQuery() { - $this->context->set('numbers', array(1, 2, 3, 4)); + public function testLengthQuery() + { + $this->context->set('numbers', [1, 2, 3, 4]); $this->assertEquals(4, $this->context->get('numbers.size')); } - public function testOverrideSize() { - $this->context->set('hash', array('a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'size' => '5000')); + public function testStringLength() + { + $this->context->set('name', 'Foo Bar'); + $this->assertEquals(7, $this->context->get('name.size')); + + $this->context->set('name', 'テスト'); + $this->assertEquals(3, $this->context->get('name.size')); + } + + public function testCountableLength() + { + $this->context->set('countable', new CountableObject()); + $this->assertEquals(2, $this->context->get('countable.size')); + } + + public function testOverrideSize() + { + $this->context->set('hash', ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'size' => '5000']); $this->assertEquals(5000, $this->context->get('hash.size')); } - public function testHierchalData() { - $this->context->set('hash', array('name' => 'tobi')); + public function testArrayFirst() + { + $this->context->set('array', [11, 'jack', 43, 74, 5, 'tom']); + $this->assertEquals(11, $this->context->get('array.first')); + } + + public function testOverrideFirst() + { + $this->context->set('array', [11, 'jack', 43, 'first' => 74, 5, 'tom']); + $this->assertEquals(74, $this->context->get('array.first')); + } + + public function testArrayLast() + { + $this->context->set('array', [11, 'jack', 43, 74, 5, 'tom']); + $this->assertEquals('tom', $this->context->get('array.last')); + } + + public function testOverrideLast() + { + $this->context->set('array', [11, 'jack', 43, 'last' => 74, 5, 'tom']); + $this->assertEquals(74, $this->context->get('array.last')); + } + + public function testDeepValueNotObject() + { + $this->context->set('example', ['foo' => new ToLiquidNotObject()]); + $this->assertNull($this->context->get('example.foo.bar')); + } + + public function testHierchalData() + { + $this->context->set('hash', ['name' => 'tobi']); $this->assertEquals('tobi', $this->context->get('hash.name')); } - public function testHierchalDataNoKey() { - $this->context->set('hash', array('name' => 'tobi')); - $this->assertNotNull('tobi', $this->context->get('hash.no_key')); + public function testHierchalDataNoKey() + { + $this->context->set('hash', ['name' => 'tobi']); + $this->assertNull($this->context->get('hash.no_key')); } - public function testAddFilter() { + public function testAddFilter() + { $context = new Context(); $context->addFilters(new HiFilter()); $this->assertEquals('hi? hi!', $context->invoke('hi', 'hi?')); @@ -165,16 +382,29 @@ public function testAddFilter() { $this->assertEquals('hi? hi!', $context->invoke('hi', 'hi?')); } - public function testOverrideGlobalFilter() { + public function testOverrideGlobalFilter() + { $template = new Template(); $template->registerFilter(new GlobalFilter()); $template->parse("{{'test' | notice }}"); $this->assertEquals('Global test', $template->render()); - $this->assertEquals('Local test', $template->render(array(), new LocalFilter())); + $this->assertEquals('Local test', $template->render([], new LocalFilter())); + } + + public function testCallbackFilter() + { + $template = new Template(); + $template->registerFilter('foo', function ($arg) { + return "Foo $arg"; + }); + + $template->parse("{{'test' | foo }}"); + $this->assertEquals('Foo test', $template->render()); } - public function testAddItemInOuterScope() { + public function testAddItemInOuterScope() + { $this->context->set('test', 'test'); $this->context->push(); $this->assertEquals('test', $this->context->get('test')); @@ -182,50 +412,57 @@ public function testAddItemInOuterScope() { $this->assertEquals('test', $this->context->get('test')); } - public function testAddItemInInnerScope() { + public function testAddItemInInnerScope() + { $this->context->push(); $this->context->set('test', 'test'); $this->assertEquals('test', $this->context->get('test')); $this->context->pop(); - $this->assertEquals(null, $this->context->get('test')); + $this->assertNull($this->context->get('test')); } - public function testMerge() { - $this->context->merge(array('test' => 'test')); + public function testMerge() + { + $this->context->merge(['test' => 'test']); $this->assertEquals('test', $this->context->get('test')); - $this->context->merge(array('test' => 'newvalue', 'foo' => 'bar')); + $this->context->merge(['test' => 'newvalue', 'foo' => 'bar']); $this->assertEquals('newvalue', $this->context->get('test')); $this->assertEquals('bar', $this->context->get('foo')); } - public function testCents() { - $this->context->merge(array('cents' => new HundredCentes())); + public function testCents() + { + $this->context->merge(['cents' => new HundredCentes()]); $this->assertEquals(100, $this->context->get('cents')); } - public function testNestedCents() { - $this->context->merge(array('cents' => array('amount' => new HundredCentes()))); + public function testNestedCents() + { + $this->context->merge(['cents' => ['amount' => new HundredCentes()]]); $this->assertEquals(100, $this->context->get('cents.amount')); - $this->context->merge(array('cents' => array('cents' => array('amount' => new HundredCentes())))); + $this->context->merge(['cents' => ['cents' => ['amount' => new HundredCentes()]]]); $this->assertEquals(100, $this->context->get('cents.cents.amount')); } - public function testCentsThroughDrop() { - $this->context->merge(array('cents' => new CentsDrop())); + public function testCentsThroughDrop() + { + $this->context->merge(['cents' => new CentsDrop()]); $this->assertEquals(100, $this->context->get('cents.amount')); } - public function testCentsThroughDropNestedly() { - $this->context->merge(array('cents' => array('cents' => new CentsDrop()))); + public function testCentsThroughDropNestedly() + { + $this->context->merge(['cents' => ['cents' => new CentsDrop()]]); $this->assertEquals(100, $this->context->get('cents.cents.amount')); - $this->context->merge(array('cents' => array('cents' => array('cents' => new CentsDrop())))); + $this->context->merge(['cents' => ['cents' => ['cents' => new CentsDrop()]]]); $this->assertEquals(100, $this->context->get('cents.cents.cents.amount')); } - public function testGetNoOverride() { + public function testGetNoOverride() + { $_GET['test'] = ''; // Previously $_GET would override directly set values // It happend during class construction - we need to create a brand new instance right here @@ -233,4 +470,40 @@ public function testGetNoOverride() { $context->set('test', 'test'); $this->assertEquals('test', $context->get('test')); } + + public function testServerOnlyExposeWhitelistByDefault() + { + $_SERVER['AWS_SECRET_ACCESS_KEY'] = 'super_secret'; + + $context = new Context(); + $this->assertNull($context->get('AWS_SECRET_ACCESS_KEY')); + + $context->set('AWS_SECRET_ACCESS_KEY', 'test'); + $this->assertEquals('test', $context->get('AWS_SECRET_ACCESS_KEY')); + + $_SERVER['FOO'] = 'foo'; + $_SERVER['BAR'] = 'bar'; + + Liquid::set('SERVER_SUPERGLOBAL_WHITELIST', ['FOO']); + + $context = new Context(); + $this->assertEquals('foo', $context->get('FOO')); + $this->assertNull($context->get('BAR')); + + $context->set('BAR', 'bar'); + $this->assertEquals('bar', $context->get('BAR')); + } + + public function testServerExposedWhenRequested() + { + Liquid::set('EXPOSE_SERVER', true); + + $_SERVER['AWS_SECRET_ACCESS_KEY'] = 'super_secret'; + + $context = new Context(); + $this->assertEquals('super_secret', $context->get('AWS_SECRET_ACCESS_KEY')); + + $context->set('AWS_SECRET_ACCESS_KEY', 'test'); + $this->assertEquals('super_secret', $context->get('AWS_SECRET_ACCESS_KEY'), '$_SERVER should take precedence in this case'); + } } diff --git a/tests/Liquid/CustomFiltersTest.php b/tests/Liquid/CustomFiltersTest.php index f5697a6d..4867de66 100644 --- a/tests/Liquid/CustomFiltersTest.php +++ b/tests/Liquid/CustomFiltersTest.php @@ -1,6 +1,6 @@ context = new Context(); } - public function testSortKey() { - $data = array( - array( - array(), - array(), - ), - array( - array('b' => 1, 'c' => 5, 'a' => 3, 'z' => 4, 'h' => 2), - array('a' => 3, 'b' => 1, 'c' => 5, 'h' => 2, 'z' => 4), - ), - ); + public function testSortKey() + { + $data = [ + [ + [], + [], + ], + [ + ['b' => 1, 'c' => 5, 'a' => 3, 'z' => 4, 'h' => 2], + ['a' => 3, 'b' => 1, 'c' => 5, 'h' => 2, 'z' => 4], + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], CustomFilters::sort_key($item[0])); } } - } diff --git a/tests/Liquid/CustomTagTest.php b/tests/Liquid/CustomTagTest.php new file mode 100644 index 00000000..ceb255a2 --- /dev/null +++ b/tests/Liquid/CustomTagTest.php @@ -0,0 +1,47 @@ +getTags())) { + $this->markTestIncomplete("Test tag already registered. Are you missing @depends?"); + } + + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('Unknown tag foo'); + + $template->parse('[ba{% foo %} Comment {% endfoo %}r]'); + } + + /** + * @depends testUnknownTag + */ + public function testCustomTag() + { + $template = new Template(); + $template->registerTag('foo', TagFoo::class); + + $template->parse('[ba{% foo %} Comment {% endfoo %}r]'); + $this->assertEquals('[bar]', $template->render()); + } +} diff --git a/tests/Liquid/DropTest.php b/tests/Liquid/DropTest.php index ce11b464..060c6b41 100644 --- a/tests/Liquid/DropTest.php +++ b/tests/Liquid/DropTest.php @@ -1,6 +1,6 @@ context->get($method); } } class TextDrop extends Drop { - public function get_array() { - return array('text1', 'text2'); + public function get_array() + { + return ['text1', 'text2']; } - public function text() { + public function text() + { return 'text1'; } } class CatchallDrop extends Drop { - public function beforeMethod($method) { + public function beforeMethod($method) + { return 'method: ' . $method; } } class ProductDrop extends Drop { - public function top_sales() { - trigger_error('worked', E_USER_ERROR); + public function top_sales() + { + throw new \Exception("worked"); } - public function texts() { + public function texts() + { return new TextDrop(); } - public function catchall() { + public function catchall() + { return new CatchallDrop(); } - public function context() { + public function context() + { return new ContextDrop(); } - public function callmenot() { + public function callmenot() + { return "protected"; } + + public function hasKey($name) + { + return $name != 'unknown' && $name != 'false'; + } } class DropTest extends TestCase { /** - * @expectedException \PHPUnit_Framework_Error - * @expectedExceptionMessage worked */ - public function testProductDrop() { + public function testProductDrop() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('worked'); + $template = new Template(); $template->parse(' {{ product.top_sales }} '); - $template->render(array('product' => new ProductDrop)); + $template->render(['product' => new ProductDrop]); + } + + public function testNoKeyDrop() + { + $template = new Template(); + $template->parse(' {{ product.invalid.unknown }}{{ product.false }} '); + $output = $template->render(['product' => new ProductDrop]); + $this->assertEquals(' ', $output); } - public function testTextDrop() { + public function testTextDrop() + { $template = new Template(); $template->parse(' {{ product.texts.text }} '); - $output = $template->render(array('product' => new ProductDrop())); + $output = $template->render(['product' => new ProductDrop()]); $this->assertEquals(' text1 ', $output); $template = new Template(); $template->parse(' {{ product.catchall.unknown }} '); - $output = $template->render(array('product' => new ProductDrop())); + $output = $template->render(['product' => new ProductDrop()]); $this->assertEquals(' method: unknown ', $output); } - public function testTextArrayDrop() { + public function testTextArrayDrop() + { $template = new Template(); $template->parse('{% for text in product.texts.get_array %} {{text}} {% endfor %}'); - $output = $template->render(array('product' => new ProductDrop())); + $output = $template->render(['product' => new ProductDrop()]); $this->assertEquals(' text1 text2 ', $output); } - public function testContextDrop() { + public function testContextDrop() + { $template = new Template(); $template->parse(' {{ context.bar }} '); - $output = $template->render(array('context' => new ContextDrop(), 'bar' => 'carrot')); + $output = $template->render(['context' => new ContextDrop(), 'bar' => 'carrot']); $this->assertEquals(' carrot ', $output); } - public function testNestedContextDrop() { + public function testNestedContextDrop() + { $template = new Template(); $template->parse(' {{ product.context.foo }} '); - $output = $template->render(array('product' => new ProductDrop(), 'foo' => 'monkey')); + $output = $template->render(['product' => new ProductDrop(), 'foo' => 'monkey']); $this->assertEquals(' monkey ', $output); } + + public function testToString() + { + $this->assertEquals(ProductDrop::class, strval(new ProductDrop())); + } } diff --git a/tests/Liquid/EscapeByDefaultTest.php b/tests/Liquid/EscapeByDefaultTest.php index 2d02e968..aa8c70f0 100644 --- a/tests/Liquid/EscapeByDefaultTest.php +++ b/tests/Liquid/EscapeByDefaultTest.php @@ -1,6 +1,6 @@ string = $string; + } + + public function __toString() + { + return $this->string; + } +} + class EscapeByDefaultTest extends TestCase { const XSS = ""; const XSS_FAILED = "<script>alert()</script>"; - protected $assigns = array(); + protected $assigns = []; - protected function setup() { + protected function setUp(): void + { parent::setUp(); - $this->assigns = array( + $this->assigns = [ 'xss' => self::XSS, - ); + ]; } - public function testUnescaped() { + public function testUnescaped() + { $text = "{{ xss }}"; $expected = self::XSS; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testEscapedManually() { + public function testEscapedManually() + { $text = "{{ xss | escape }}"; $expected = self::XSS_FAILED; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testRawWithoutAutoEscape() { + public function testRawWithoutAutoEscape() + { $text = "{{ xss | raw }}"; $expected = self::XSS; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testEscapedAutomatically() { + public function testEscapedAutomatically() + { Liquid::set('ESCAPE_BY_DEFAULT', true); $text = "{{ xss }}"; @@ -52,7 +72,8 @@ public function testEscapedAutomatically() { $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testEscapedManuallyInAutoMode() { + public function testEscapedManuallyInAutoMode() + { Liquid::set('ESCAPE_BY_DEFAULT', true); // text should only be escaped once @@ -61,7 +82,8 @@ public function testEscapedManuallyInAutoMode() { $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testRawInAutoMode() { + public function testRawInAutoMode() + { Liquid::set('ESCAPE_BY_DEFAULT', true); $text = "{{ xss | raw }}"; @@ -69,23 +91,37 @@ public function testRawInAutoMode() { $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testNlToBr() { + public function testNlToBr() + { Liquid::set('ESCAPE_BY_DEFAULT', true); $text = "{{ xss | newline_to_br }}"; - $expected = self::XSS."
".self::XSS; - $this->assertTemplateResult($expected, $text, array('xss' => self::XSS."\n".self::XSS)); + $expected = self::XSS."
\n".self::XSS; + $this->assertTemplateResult($expected, $text, ['xss' => self::XSS."\n".self::XSS]); + } + + public function testToStringEscape() + { + $this->assertTemplateResult(self::XSS_FAILED, "{{ xss | escape }}", ['xss' => new ObjectWithToString(self::XSS)]); + } + + public function testToStringEscapeDefault() + { + Liquid::set('ESCAPE_BY_DEFAULT', true); + $this->assertTemplateResult(self::XSS_FAILED, "{{ xss }}", ['xss' => new ObjectWithToString(self::XSS)]); } /** System default value for the escape flag */ private static $escapeDefault; - public static function setUpBeforeClass() { + public static function setUpBeforeClass(): void + { // save system default value for the escape flag before all tests self::$escapeDefault = Liquid::get('ESCAPE_BY_DEFAULT'); } - public function tearDown() { + protected function tearDown(): void + { // reset to the default after each test Liquid::set('ESCAPE_BY_DEFAULT', self::$escapeDefault); } -} \ No newline at end of file +} diff --git a/tests/Liquid/FilterbankTest.php b/tests/Liquid/FilterbankTest.php index dbb84e86..09eaf026 100644 --- a/tests/Liquid/FilterbankTest.php +++ b/tests/Liquid/FilterbankTest.php @@ -1,6 +1,6 @@ variable = 'set'; - return 'set'; - } - - public function instance_test_two() { - return $this->variable; - } -} - -} // global namespace - -namespace Liquid { - -class FilterbankTest extends TestCase -{ - /** @var FilterBank */ - private $filterBank; - - /** @var Context */ - private $context; - - protected function setup() { - parent::setUp(); - - $this->context = new Context(); - $this->filterBank = new FilterBank($this->context); - } + use Liquid\Context; /** - * @expectedException \Liquid\LiquidException + * Global function acts as a filter. + * + * @param $value + * + * @return string */ - public function testAddFilterNotObjectAndString() { - $this->filterBank->addFilter(array()); + function functionFilter($value) + { + return 'worked'; } /** - * @expectedException \Liquid\LiquidException + * Global filter class */ - public function testAddFilterNoFunctionOrClass() { - $this->filterBank->addFilter('no_such_function_or_class'); + class ClassFilter + { + public Context $context; + + private $variable = 'not set'; + + public function __construct() + { + } + + public static function static_test() + { + return "worked"; + } + + public function instance_test_one() + { + $this->variable = 'set'; + return 'set'; + } + + public function instance_test_two() + { + return $this->variable; + } } - public function testInvokeNoFilter() { - $value = 'value'; - $this->assertEquals($value, $this->filterBank->invoke('non_existing_filter', $value)); - } +} // global namespace - /** - * Test using a simple function - */ - public function testFunctionFilter() { - $var = new Variable('var | functionFilter'); - $this->context->set('var', 1000); - $this->context->addFilters('functionFilter'); - $this->assertEquals('worked', $var->render($this->context)); - } +namespace Liquid { - /** - * Test using a static class - */ - public function testStaticClassFilter() { - $var = new Variable('var | static_test'); - $this->context->set('var', 1000); - $this->context->addFilters('\ClassFilter'); - $this->assertEquals('worked', $var->render($this->context)); + use Liquid\Cache\File; + + class NamespacedClassFilter + { + public Context $context; + + public static function static_test2($var) + { + return "good {$var}"; + } } - /** - * Test using an object as a filter; an object fiter will retain its state - * between calls to its filters. - */ - public function testObjectFilter() { - $var = new Variable('var | instance_test_one'); - $this->context->set('var', 1000); - $this->context->addFilters(new \ClassFilter()); - $this->assertEquals('set', $var->render($this->context)); - - $var = new Variable('var | instance_test_two'); - $this->assertEquals('set', $var->render($this->context)); + class FilterbankTest extends TestCase + { + /** @var FilterBank */ + private $filterBank; + + /** @var Context */ + private $context; + + protected function setUp(): void + { + parent::setUp(); + + $this->context = new Context(); + $this->filterBank = new FilterBank($this->context); + } + + protected function tearDown(): void + { + // have to destroy these else PHP goes nuts + unset($this->context); + unset($this->filterBank); + } + + /** + */ + public function testAddFilterNotObjectAndString() + { + $this->expectException(\Liquid\Exception\WrongArgumentException::class); + + $this->filterBank->addFilter([]); + } + + /** + */ + public function testAddFilterNoFunctionOrClass() + { + $this->expectException(\Liquid\Exception\WrongArgumentException::class); + + $this->filterBank->addFilter('no_such_function_or_class'); + } + + public function testTypeErrorExceptionAndCallDateFilterWithoutArguments() + { + if (\PHP_VERSION_ID < 70100) { + $this->markTestSkipped('TypeError is not thrown in PHP 7.0'); + } + + $var = new Variable('var | date'); + $this->context->set('var', []); + + $this->expectException(\Liquid\LiquidException::class); + $var->render($this->context); + } + + public function testInvokeNoFilter() + { + $value = 'value'; + $this->assertEquals($value, $this->filterBank->invoke('non_existing_filter', $value)); + } + + /** + * Test using a simple function + */ + public function testFunctionFilter() + { + $var = new Variable('var | functionFilter'); + $this->context->set('var', 1000); + $this->context->addFilters('functionFilter'); + $this->assertEquals('worked', $var->render($this->context)); + } + + /** + * Test using a namespaced static class + */ + public function testNamespacedStaticClassFilter() + { + $var = new Variable('var | static_test2'); + $this->context->set('var', 1000); + $this->context->addFilters(NamespacedClassFilter::class); + $this->assertEquals('good 1000', $var->render($this->context)); + } + + /** + * Test using a static class + */ + public function testStaticClassFilter() + { + $var = new Variable('var | static_test'); + $this->context->set('var', 1000); + $this->context->addFilters(\ClassFilter::class); + $this->assertEquals('worked', $var->render($this->context)); + } + + /** + * Test with instance method on a static class + */ + public function testStaticMixedClassFilter() + { + $var = new Variable('var | instance_test_one'); + $this->context->set('var', 'foo'); + $this->context->addFilters(\ClassFilter::class); + $this->assertEquals('foo', $var->render($this->context)); + } + + /** + * Test using an object as a filter; an object fiter will retain its state + * between calls to its filters. + */ + public function testObjectFilter() + { + $var = new Variable('var | instance_test_one'); + $this->context->set('var', 1000); + $this->context->addFilters(new \ClassFilter()); + $this->assertEquals('set', $var->render($this->context)); + + $var = new Variable('var | instance_test_two'); + $this->assertEquals('set', $var->render($this->context)); + + $var = new Variable('var | static_test'); + $this->assertEquals('worked', $var->render($this->context)); + + $var = new Variable('var | __construct'); + $this->assertEquals('1000', $var->render($this->context)); + } + + public function testObjectFilterDontCallConstruct() + { + $this->context->set('var', 1000); + $this->context->addFilters(new \ClassFilter()); + + $filterbankReflectionClass = new \ReflectionClass(Context::class); + $methodMapProperty = $filterbankReflectionClass->getProperty('filterbank'); + $methodMapProperty->setAccessible(true); + $filterbank = $methodMapProperty->getValue($this->context); + + $filterbankReflectionClass = new \ReflectionClass(Filterbank::class); + $methodMapProperty = $filterbankReflectionClass->getProperty('methodMap'); + $methodMapProperty->setAccessible(true); + $methodMap = $methodMapProperty->getValue($filterbank); + + $this->assertArrayNotHasKey('__construct', $methodMap); + + $var = new Variable('var | __construct'); + $this->assertEquals('1000', $var->render($this->context)); + } + + public function testCallbackFilter() + { + $var = new Variable('var | my_callback'); + $this->context->set('var', 1000); + $this->context->addFilters('my_callback', function ($var) { + return $var * 2; + }); + $this->assertEquals('2000', $var->render($this->context)); + } + + /** + * Closures are not to be serialized. Let's check that. + */ + public function testWithSerializingCache() + { + $template = new Template(); + $template->registerFilter('foo', function ($arg) { + return "Foo $arg"; + }); + $template->setCache(new File([ + 'cache_dir' => __DIR__.'/cache_dir/', + ])); + $template->parse("{{'test' | foo }}"); + $this->assertEquals('Foo test', $template->render()); + + $template->parse("{{'bar' | foo }}"); + $this->assertEquals('Foo bar', $template->render()); + } } -} } // Liquid namespace diff --git a/tests/Liquid/FixturesTest.php b/tests/Liquid/FixturesTest.php new file mode 100644 index 00000000..07e112b1 --- /dev/null +++ b/tests/Liquid/FixturesTest.php @@ -0,0 +1,50 @@ +setFileSystem(new Virtual(function ($filename) { + if (is_file(__DIR__.'/fixtures/'.$filename)) { + return file_get_contents(__DIR__.'/fixtures/'.$filename); + } + })); + + $template->parse(file_get_contents($liquid)); + $result = $template->render(include $data); + + if (getenv('GOLDEN') !== false) { + file_put_contents($expected, $result); + $this->markTestIncomplete("Saved golden fixture"); + } + + $this->assertEquals(file_get_contents($expected), $result); + } + + public function fixtures() + { + foreach (array_map(null, glob(__DIR__.'/fixtures/*.liquid'), glob(__DIR__.'/fixtures/*.php'), glob(__DIR__.'/fixtures/*.html')) as $files) { + yield basename($files[0], '.liquid') => $files; + }; + } +} diff --git a/tests/Liquid/LiquidTest.php b/tests/Liquid/LiquidTest.php index 21641f37..f90ba536 100644 --- a/tests/Liquid/LiquidTest.php +++ b/tests/Liquid/LiquidTest.php @@ -1,6 +1,6 @@ assertNull(Liquid::get('no_such_value')); } - public function testSetProperty() { + public function testSetProperty() + { $key = 'test_key'; $value = 'test_value'; Liquid::set($key, $value); $this->assertSame($value, Liquid::get($key)); } - public function testArrayFlattenEmptyArray() { - $this->assertSame(array(), Liquid::arrayFlatten(array())); + public function testGetSetAllowedChars() + { + Liquid::set('ALLOWED_VARIABLE_CHARS', 'abc'); + $this->assertSame('abc', Liquid::get('ALLOWED_VARIABLE_CHARS')); + $this->assertSame('abc+', Liquid::get('VARIABLE_NAME')); } - public function testArrayFlattenFlatArray() { + public function testArrayFlattenEmptyArray() + { + $this->assertSame([], Liquid::arrayFlatten([])); + } + + public function testArrayFlattenFlatArray() + { $object = new \stdClass(); // Method does not maintain keys. - $original = array( + $original = [ 'one' => 'one_value', 42, $object, - ); + ]; - $expected = array( + $expected = [ 'one_value', 42, - $object - ); + $object, + ]; $this->assertEquals($expected, Liquid::arrayFlatten($original)); } - public function testArrayFlattenNestedArray() { + public function testArrayFlattenNestedArray() + { $object = new \stdClass(); // Method does not maintain keys. - $original = array( + $original = [ 'one' => 'one_value', - 42 => array( + 42 => [ 'one_value', - array( + [ 'two_value', - 10 - ), - ), + 10, + ], + ], $object, - ); + ]; - $expected = array( + $expected = [ 'one_value', 'one_value', 'two_value', 10, - $object - ); + $object, + ]; $this->assertEquals($expected, Liquid::arrayFlatten($original)); } diff --git a/tests/Liquid/LocalFileSystemTest.php b/tests/Liquid/LocalFileSystemTest.php index 834c1c58..c6165cbb 100644 --- a/tests/Liquid/LocalFileSystemTest.php +++ b/tests/Liquid/LocalFileSystemTest.php @@ -1,6 +1,6 @@ root = __DIR__ . DIRECTORY_SEPARATOR . self::TEMPLATES_DIR . DIRECTORY_SEPARATOR; + // reset to defaults + Liquid::set('INCLUDE_ALLOW_EXT', false); + } + + /** + */ + public function testIllegalTemplateNameEmpty() + { + $this->expectException(\Liquid\LiquidException::class); + + $fileSystem = new Local(''); + $fileSystem->fullPath(''); + } + /** - * @expectedException \Liquid\LiquidException */ - public function testIllegalTemplateNameEmpty() { - $fileSystem = new LocalFileSystem(''); + public function testIllegalRootPath() + { + $this->expectException(\Liquid\LiquidException::class); + + $fileSystem = new Local('invalid/not/found'); $fileSystem->fullPath(''); } /** - * @expectedException \Liquid\LiquidException */ - public function testIllegalTemplateNameIncludeExtension() { + public function testIllegalTemplateNameIncludeExtension() + { + $this->expectException(\Liquid\LiquidException::class); + Liquid::set('INCLUDE_ALLOW_EXT', false); - $fileSystem = new LocalFileSystem(''); + $fileSystem = new Local(''); $fileSystem->fullPath('has_extension.ext'); } /** - * @expectedException \Liquid\LiquidException */ - public function testIllegalTemplateNameNotIncludeExtension() { + public function testIllegalTemplateNameNotIncludeExtension() + { + $this->expectException(\Liquid\LiquidException::class); + Liquid::set('INCLUDE_ALLOW_EXT', true); - $fileSystem = new LocalFileSystem(''); + $fileSystem = new Local(''); $fileSystem->fullPath('has_extension'); } /** - * @expectedException \Liquid\LiquidException */ - public function testIllegalTemplatePathNoRoot() { - $fileSystem = new LocalFileSystem(''); + public function testIllegalTemplatePathNoRoot() + { + $this->expectException(\Liquid\LiquidException::class); + + $fileSystem = new Local(''); $fileSystem->fullPath('mypartial'); } /** - * @expectedException \Liquid\LiquidException */ - public function testIllegalTemplatePathNoFileExists() { - $fileSystem = new LocalFileSystem(dirname(__DIR__)); + public function testIllegalTemplatePathNoFileExists() + { + $this->expectException(\Liquid\LiquidException::class); + + $fileSystem = new Local(dirname(__DIR__)); $fileSystem->fullPath('no_such_file_exists'); } - public function testValidPathWithDefaultExtension() { - $root = dirname(__FILE__) . DIRECTORY_SEPARATOR . self::TEMPLATES_DIR . DIRECTORY_SEPARATOR; + /** + */ + public function testIllegalTemplatePathNotUnderTemplateRoot() + { + $this->expectException(\Liquid\LiquidException::class); + $this->expectExceptionMessage('not under'); + + Liquid::set('INCLUDE_ALLOW_EXT', true); + $fileSystem = new Local(dirname($this->root)); + // find any fail under deeper under the root, so all other checks would pass + $filesUnderCurrentDir = array_map('basename', glob(dirname(__DIR__).'/../*')); + // path relative to root; we can't start it with a dot since it isn't allowed anyway + $fileSystem->fullPath(self::TEMPLATES_DIR."/../../../{$filesUnderCurrentDir[0]}"); + } + + public function testValidPathWithDefaultExtension() + { $templateName = 'mypartial'; - $fileSystem = new LocalFileSystem($root); - $this->assertEquals($root . Liquid::get('INCLUDE_PREFIX') . $templateName . '.' . Liquid::get('INCLUDE_SUFFIX'), $fileSystem->fullPath($templateName)); + $fileSystem = new Local($this->root); + $this->assertEquals($this->root . Liquid::get('INCLUDE_PREFIX') . $templateName . '.' . Liquid::get('INCLUDE_SUFFIX'), $fileSystem->fullPath($templateName)); } - public function testValidPathWithCustomExtension() { + public function testValidPathWithCustomExtension() + { Liquid::set('INCLUDE_PREFIX', ''); Liquid::set('INCLUDE_SUFFIX', 'tpl'); - $root = dirname(__FILE__) . DIRECTORY_SEPARATOR . self::TEMPLATES_DIR . DIRECTORY_SEPARATOR; $templateName = 'mypartial'; - $fileSystem = new LocalFileSystem($root); - $this->assertEquals($root . Liquid::get('INCLUDE_PREFIX') . $templateName . '.' . Liquid::get('INCLUDE_SUFFIX'), $fileSystem->fullPath($templateName)); + $fileSystem = new Local($this->root); + $this->assertEquals($this->root . Liquid::get('INCLUDE_PREFIX') . $templateName . '.' . Liquid::get('INCLUDE_SUFFIX'), $fileSystem->fullPath($templateName)); + } + + /** + */ + public function testReadIllegalTemplatePathNoFileExists() + { + $this->expectException(\Liquid\LiquidException::class); + $this->expectExceptionMessage('File not found'); + + $fileSystem = new Local(dirname(__DIR__)); + $fileSystem->readTemplateFile('no_such_file_exists'); } - public function testReadTemplateFile() { + public function testReadTemplateFile() + { Liquid::set('INCLUDE_PREFIX', ''); Liquid::set('INCLUDE_SUFFIX', 'tpl'); - $root = dirname(__FILE__) . DIRECTORY_SEPARATOR . self::TEMPLATES_DIR . DIRECTORY_SEPARATOR; - - $fileSystem = new LocalFileSystem($root); + $fileSystem = new Local($this->root); $this->assertEquals('test content', trim($fileSystem->readTemplateFile('mypartial'))); } + + public function testDeprecatedLocalFileSystemExists() + { + $this->assertInstanceOf(Local::class, new LocalFileSystem($this->root)); + } + + public function testParseTemplateFile() + { + Liquid::set('INCLUDE_PREFIX', ''); + Liquid::set('INCLUDE_SUFFIX', 'tpl'); + + $template = new Template($this->root); + $this->assertEquals("test content" . PHP_EOL, $template->parseFile('mypartial')->render()); + } + + /** + */ + public function testParseTemplateFileError() + { + $this->expectException(\Liquid\LiquidException::class); + $this->expectExceptionMessage('Could not load a template'); + + $template = new Template(); + $template->parseFile('mypartial'); + } } diff --git a/tests/Liquid/OutputTest.php b/tests/Liquid/OutputTest.php index b31c518d..de035b73 100644 --- a/tests/Liquid/OutputTest.php +++ b/tests/Liquid/OutputTest.php @@ -1,6 +1,6 @@ " . $input . ""; } - public function paragraph($input) { + public function paragraph($input) + { return "

" . $input . "

"; } - public function link_to($name, $url, $protocol) { + public function link_to($name, $url, $protocol) + { return "" . $name . ""; } + + public function str_replace($input, $data) + { + foreach ($data as $k => $v) { + $input = str_replace("[" . $k . "]", $v, $input); + } + return $input; + } + + public function img_url($input, $size, $opts = null) + { + $output = "image_" . $size; + if (isset($opts['crop'])) { + $output .= "_cropped_" . $opts['crop']; + } + if (isset($opts['scale'])) { + $output .= "@" . $opts['scale'] . 'x'; + } + return $output . ".png"; + } } class OutputTest extends TestCase { - protected $assigns = array(); + protected $assigns = []; - protected function setup() { + protected function setUp(): void + { parent::setUp(); - $this->assigns = array( + $this->assigns = [ 'best_cars' => 'bmw', - 'car' => array('bmw' => 'good', 'gm' => 'bad') - ); + 'car' => ['bmw' => 'good', 'gm' => 'bad'], + ]; $this->filters = new FunnyFilter(); } - public function testVariable() { + public function testVariable() + { $text = " {{best_cars}} "; $expected = " bmw "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testVariableTrasversing() { + public function testVariableTrasversing() + { $text = " {{car.bmw}} {{car.gm}} {{car.bmw}} "; $expected = " good bad good "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testVariablePiping() { + public function testVariablePiping() + { $text = " {{ car.gm | make_funny }} "; - $expectd = " LOL "; + $expected = " LOL "; - $this->assertTemplateResult($expectd, $text, $this->assigns); + $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testVariablePipingWithInput() { + public function testVariablePipingWithInput() + { $text = " {{ car.gm | cite_funny }} "; - $expectd = " LOL: bad "; + $expected = " LOL: bad "; - $this->assertTemplateResult($expectd, $text, $this->assigns); + $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testVariablePipingWithArgs() { + public function testVariablePipingWithArgs() + { $text = " {{ car.gm | add_smiley : '=(' }} "; $expected = " bad =( "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function textVariablePipingWithNoArgs() { + public function textVariablePipingWithNoArgs() + { $text = " {{ car.gm | add_smile }} "; $expected = " bad =( "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testMultipleVariablePipingWithArgs() { + public function testMultipleVariablePipingWithArgs() + { $text = " {{ car.gm | add_smiley : '=(' | add_smiley : '=('}} "; $expected = " bad =( =( "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testVariablePipingWithTwoArgs() { + public function testVariablePipingWithTwoArgs() + { $text = " {{ car.gm | add_tag : 'span', 'bar'}} "; $expected = " bad "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testVariablePipingWithVariableArgs() { + public function testVariablePipingWithVariableArgs() + { $text = " {{ car.gm | add_tag : 'span', car.bmw}} "; $expected = " bad "; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testMultiplePipings() { + public function testVariablePipingWithKeywordArg() + { + $text = " {{ 'Welcome, [name]' | str_replace: name: 'Santa' }} "; + $expected = " Welcome, Santa "; + + $this->assertTemplateResult($expected, $text, $this->assigns); + } + + public function testVariablePipingWithArgsAndKeywordArgs() + { + $text = " {{ car.gm | img_url: '450x450', crop: 'center', scale: 2 }} "; + $expected = " image_450x450_cropped_center@2x.png "; + + $this->assertTemplateResult($expected, $text, $this->assigns); + } + + public function testMultiplePipings() + { $text = " {{ best_cars | cite_funny | paragraph }} "; $expected = "

LOL: bmw

"; $this->assertTemplateResult($expected, $text, $this->assigns); } - public function testLinkTo() { + public function testLinkTo() + { $text = " {{ 'Typo' | link_to: 'typo.leetsoft.com':'http' }} "; $expected = " Typo "; $this->assertTemplateResult($expected, $text, $this->assigns); } + + /** + */ + public function testVariableWithANewLine() + { + $text = "{{ aaa\n }}"; + $this->assertTemplateResult('', $text, $this->assigns); + } + + public function testFilterArray() + { + $text = ' {{ cars | where: "model", "bmw" | json }} '; + $expected = ' [{"model":"bmw"}] '; + + $this->assertTemplateResult($expected, $text, ['cars' => [ + ['model' => 'bmw'], + ['model' => 'audi'], + ]]); + } + + public function testFilterArrayTruthy() + { + $text = ' {{ cars | where: "available" | json }} '; + $expected = ' [{"model":"bmw","available":1}] '; + + $this->assertTemplateResult($expected, $text, ['cars' => [ + ['model' => 'bmw', 'available' => 1], + ['model' => 'audi'], + ['model' => 'toyota', 'available' => false], + ]]); + } + + public function testFilterArrayNull() + { + $text = ' {{ cars | where: "available" | json }} '; + $expected = ' null '; + + $this->assertTemplateResult($expected, $text, []); + } } diff --git a/tests/Liquid/ParsingQuirksTest.php b/tests/Liquid/ParsingQuirksTest.php index bcd530c1..ce0db792 100644 --- a/tests/Liquid/ParsingQuirksTest.php +++ b/tests/Liquid/ParsingQuirksTest.php @@ -1,6 +1,6 @@ parse($text); @@ -21,6 +22,6 @@ public function testErrorWithCss() { $nodelist = $template->getRoot()->getNodelist(); $this->assertEquals($text, $template->render()); - $this->assertInternalType('string', $nodelist[0]); + $this->assertIsString($nodelist[0]); } } diff --git a/tests/Liquid/RegexpTest.php b/tests/Liquid/RegexpTest.php index fc1d0296..30b7f1d2 100644 --- a/tests/Liquid/RegexpTest.php +++ b/tests/Liquid/RegexpTest.php @@ -1,6 +1,6 @@ regexp = new Regexp('/' . Liquid::get('QUOTED_FRAGMENT') . '/'); } - public function testEmpty() { - $this->assertEquals(array(), $this->regexp->scan('')); + public function testEmpty() + { + $this->assertEquals([], $this->regexp->scan('')); } - public function testQuote() { - $this->assertEquals(array('"arg 1"'), $this->regexp->scan('"arg 1"')); + public function testQuote() + { + $this->assertEquals(['"arg 1"'], $this->regexp->scan('"arg 1"')); } - public function testWords() { - $this->assertEquals(array('arg1', 'arg2'), $this->regexp->scan('arg1 arg2')); + public function testWords() + { + $this->assertEquals(['arg1', 'arg2'], $this->regexp->scan('arg1 arg2')); } - public function testQuotedWords() { - $this->assertEquals(array('arg1', 'arg2', '"arg 3"'), $this->regexp->scan('arg1 arg2 "arg 3"')); + public function testQuotedWords() + { + $this->assertEquals(['arg1', 'arg2', '"arg 3"'], $this->regexp->scan('arg1 arg2 "arg 3"')); } - public function testQuotedWords2() { - $this->assertEquals(array('arg1', 'arg2', "'arg 3'"), $this->regexp->scan('arg1 arg2 \'arg 3\'')); + public function testQuotedWords2() + { + $this->assertEquals(['arg1', 'arg2', "'arg 3'"], $this->regexp->scan('arg1 arg2 \'arg 3\'')); } - public function testQuotedWordsInTheMiddle() { - $this->assertEquals(array('arg1', 'arg2', '"arg 3"', 'arg4'), $this->regexp->scan('arg1 arg2 "arg 3" arg4 ')); + public function testQuotedWordsInTheMiddle() + { + $this->assertEquals(['arg1', 'arg2', '"arg 3"', 'arg4'], $this->regexp->scan('arg1 arg2 "arg 3" arg4 ')); + } + + public function testPregQuote() + { + $this->assertEquals('', $this->regexp->quote('')); + $this->assertEquals('abc', $this->regexp->quote('abc')); + $this->assertEquals('\/\(\{\}\)\/', $this->regexp->quote('/({})/')); + } + + public function testNoDelimiter() + { + $regexp = new Regexp('(example)'); + $this->assertEquals(['(example)'], $regexp->scan('(example)')); + $this->assertEquals([], $regexp->scan('nothing')); } } diff --git a/tests/Liquid/StandardFiltersTest.php b/tests/Liquid/StandardFiltersTest.php index 7257c751..c7b04f1e 100644 --- a/tests/Liquid/StandardFiltersTest.php +++ b/tests/Liquid/StandardFiltersTest.php @@ -1,6 +1,6 @@ context = new Context(); } - public function testSize() { - $data = array( + public function testSize() + { + $data = [ 4 => 1000, 3 => 100, - 2 => array('one', 'two'), + 2 => ['one', 'two'], + 1 => new \ArrayIterator(['one']), SizeClass::SIZE => new SizeClass(), - ); + ]; foreach ($data as $expected => $element) { $this->assertEquals($expected, StandardFilters::size($element)); } } - public function testDowncase() { - $data = array( + /** + */ + public function testSizeObject() + { + $this->expectException(\Liquid\LiquidException::class); + $this->expectExceptionMessage('cannot be estimated'); + + StandardFilters::size((object) []); + } + + public function testDowncase() + { + $data = [ 'UpperCaseMiXed' => 'uppercasemixed', 3 => 3, - ); + // UTF-8 + 'Владимир' => 'владимир', + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::downcase($element)); } } - public function testUpcase() { - $data = array( + public function testUpcase() + { + $data = [ 'UpperCaseMiXed' => 'UPPERCASEMIXED', 3 => 3, - ); + // UTF-8 + 'владимир' => 'ВЛАДИМИР', + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::upcase($element)); } } - public function testCapitalize() { - $data = array( + public function testCapitalize() + { + $data = [ 'one Word not' => 'One Word Not', + '1test' => '1Test', '' => '', - ); + // UTF-8 + 'владимир владимирович' => 'Владимир Владимирович', + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::capitalize($element)); } } + public function testUrlEncode() + { + $data = [ + 'nothing' => 'nothing', + '%#&^' => '%25%23%26%5E', + ]; + + foreach ($data as $element => $expected) { + $this->assertEquals($expected, StandardFilters::url_encode($element)); + } + } + + + public function testUrlDecode() + { + $data = [ + '%25%23%26%5E' => '%#&^', + ]; + + foreach ($data as $element => $expected) { + $this->assertEquals($expected, StandardFilters::url_decode($element)); + } + } + + public function testRaw() { - $data = array( + $data = [ "Anything" => "Anything", 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::raw($element)); } } - public function testEscape() { - $data = array( + public function testJson() + { + $data = [ + [ + "before" => "Anything", + "after" => "\"Anything\"", + ], + [ + "before" => 3, + "after" => 3, + ], + [ + "before" => [1, 2, 3], + "after" => "[1,2,3]", + ], + [ + "before" => ["one" => 1, "two" => 2, "three" => 3], + "after" => "{\"one\":1,\"two\":2,\"three\":3}", + ], + [ + "before" => ["one" => 1, "two" => [1, 2, 3], "three" => 3], + "after" => "{\"one\":1,\"two\":[1,2,3],\"three\":3}", + ], + ]; + + foreach ($data as $testCase) { + $this->assertEquals($testCase['after'], StandardFilters::json($testCase['before'])); + } + } + + public function testWhere() + { + $data = [ + [ + 'before' => [['model' => 'bmw'], ['model' => 'audi']], + 'after' => [['model' => 'bmw']], + ], + [ + 'before' => ['model' => 'bmw'], + 'after' => [], + ], + ]; + + foreach ($data as $testCase) { + $this->assertEquals($testCase['after'], StandardFilters::where($testCase['before'], 'model', 'bmw')); + } + } + + public function testEscape() + { + $data = [ "one Word's not" => "one Word's not", "&><\"'" => "&><"'", - ); + null => '', + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::escape($element)); } + + $this->assertSame([1], StandardFilters::escape([1])); } - public function testEscapeOnce() { - $data = array( + public function testEscapeOnce() + { + $data = [ "" => "<b><script>alert()</script>", "a < b & c" => "a < b & c", "a < b & c" => "a < b & c", "<\">" => "<">", - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::escape_once($element)); } + + $this->assertSame([1], StandardFilters::escape_once([1])); } - public function testStripNewLines() { - $data = array( + public function testStripNewLines() + { + $data = [ "one Word\r\n not\r\n\r\n" => "one Word not", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::strip_newlines($element)); } } - public function testNewLineToBr() { - $data = array( - "one Word\r\n not\r\n" => "one Word

not

", + public function testNewLineToBr() + { + $data = [ + "one Word\n not\n" => "one Word
\n not
\n", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::newline_to_br($element)); } } - public function testReplace() { + public function testReplace() + { // Replace for empty string - $data = array( + $data = [ "one Word not Word" => "one not ", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::replace($element, 'Word')); } // Replace for "Hello" string - $data = array( + $data = [ "one Word not Word" => "one Hello not Hello", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::replace($element, 'Word', 'Hello')); } } - public function testReplaceFirst() { + public function testReplaceFirst() + { // Replace for empty string - $data = array( + $data = [ "one Word not Word" => "one not Word", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::replace_first($element, 'Word')); } // Replace for "Hello" string - $data = array( + $data = [ "one Word not Word" => "one Hello not Word", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::replace_first($element, 'Word', 'Hello')); } } - public function testRemove() { - $data = array( + public function testRemove() + { + $data = [ "one Word not Word" => "one not ", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::remove($element, 'Word')); } } - public function testRemoveFirst() { - $data = array( + public function testRemoveFirst() + { + $data = [ "one Word not Word" => "one not Word", 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::remove_first($element, 'Word')); } } - public function testAppend() { - $data = array( + public function testAppend() + { + $data = [ "one Word not Word" => "one Word not Word appended", '' => ' appended', 3 => '3 appended', - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::append($element, ' appended')); } } - public function testPrepend() { - $data = array( + public function testPrepend() + { + $data = [ "one Word not Word" => "prepended one Word not Word", '' => 'prepended ', 3 => 'prepended 3', - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::prepend($element, 'prepended ')); } } - public function testSlice() { - // Slize up to the end - $data = array( - array( - array(), - array(), - ), - array( + public function testSlice() + { + // Slice up to the end + $data = [ + [ + [], + [], + ], + [ + new \ArrayIterator([]), + [], + ], + [ '', '', - ), - array( - array(1,2,3,4,5), - array(3,4,5), - ), - array( + ], + [ + [1, 2, 3, 4, 5], + [3, 4, 5], + ], + [ + new \ArrayIterator([1, 2, 3, 4, 5]), + [3, 4, 5], + ], + [ '12345', - '345' - ), - array( + '345', + ], + [ 100, - 100 - ), - ); + 100, + ], + ]; foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::slice($item[0], 2)); + $actual = StandardFilters::slice($item[0], 2); + if ($actual instanceof \Traversable) { + $actual = iterator_to_array($actual); + } + $this->assertEquals($item[1], $actual); } - // Slize a few elements - $data = array( - array( - array(), - array(), - ), - array( + // Slice a few elements + $data = [ + [ + null, + null, + ], + [ + [], + [], + ], + [ + new \ArrayIterator([]), + [], + ], + [ '', '', - ), - array( - array(1,2,3,4,5), - array(3,4), - ), - array( + ], + [ + [1, 2, 3, 4, 5], + [3, 4], + ], + [ + new \ArrayIterator([1, 2, 3, 4, 5]), + [3, 4], + ], + [ '12345', - '34' - ), - array( + '34', + ], + [ + 100, 100, - 100 - ), - ); + ], + ]; foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::slice($item[0], 2, 2)); + $actual = StandardFilters::slice($item[0], 2, 2); + if ($actual instanceof \Traversable) { + $actual = iterator_to_array($actual); + } + $this->assertEquals($item[1], $actual); } + + $this->assertEquals('Владимир', StandardFilters::slice('Владимир Владимирович', 0, 8)); } - public function testTruncate() { + public function testTruncate() + { // Truncate with default ending - $data = array( + $data = [ '' => '', str_repeat('a', 150) => str_repeat('a', 100) . '...', 'test' => 'test', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::truncate($element)); @@ -336,16 +493,20 @@ public function testTruncate() { // Custom ending $this->assertEquals('abcend', StandardFilters::truncate('abcdef', 3, 'end')); + + // UTF-8 + $this->assertEquals('Влад...', StandardFilters::truncate('Владимир Владимирович', 4)); } - public function testTruncateWords() { + public function testTruncateWords() + { // Truncate with default ending - $data = array( + $data = [ '' => '', str_repeat('abc ', 10) => rtrim(str_repeat('abc ', 3)) . '...', 'test two' => 'test two', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::truncatewords($element)); @@ -358,495 +519,723 @@ public function testTruncateWords() { $this->assertEquals('helloend', StandardFilters::truncatewords('hello from string', 1, 'end')); } - public function testStripHtml() { - $data = array( + public function testStripHtml() + { + $data = [ '' => '', 'test no html tags' => 'test no html tags', 'test

paragraph

hello' => 'test paragraph hello', 3 => 3, - ); + ]; foreach ($data as $element => $expected) { $this->assertEquals($expected, StandardFilters::strip_html($element)); } } - public function testJoin() { - $data = array( - array( - array(), + public function testJoin() + { + $data = [ + [ + [], '', - ), - array( + ], + [ + new \ArrayIterator([]), '', + ], + [ '', - ), - array( - array(1,2,3,4,5), - '1 2 3 4 5' - ), - array( + '', + ], + [ + [1, 2, 3, 4, 5], + '1 2 3 4 5', + ], + [ + new \ArrayIterator([1, 2, 3, 4, 5]), + '1 2 3 4 5', + ], + [ + 100, 100, - 100 - ), - ); + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::join($item[0])); } // Custom glue - $this->assertEquals('1-2-3', StandardFilters::join(array(1, 2, 3), '-')); + $this->assertEquals('1-2-3', StandardFilters::join([1, 2, 3], '-')); + $this->assertEquals('1-2-3', StandardFilters::join(new \ArrayIterator([1, 2, 3]), '-')); } - public function testSort() { - $data = array( - array( - array(), - array(), - ), - array( - array(1, 5, 3, 4, 2), - array(1, 2, 3, 4, 5), - ), - ); - - foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::sort($item[0]), '', 0, 10, true); + public function testSort() + { + $data = [ + [ + [], + [], + ], + [ + new \ArrayIterator([]), + [], + ], + [ + [1, 5, 3, 4, 2], + [1, 2, 3, 4, 5], + ], + [ + new \ArrayIterator([1, 5, 3, 4, 2]), + [1, 2, 3, 4, 5], + ], + ]; + + foreach ($data as $key => $item) { + $this->assertEquals(array_values($item[1]), array_values(StandardFilters::sort($item[0])), "Sort failed for case #{$key}"); } // Sort by inner key - $original = array( - array('a' => 20, 'b' => 10), - array('a' => 45, 'b' => 5), - array('a' => 30, 'b' => 48), - ); - $expected = array( - array('a' => 45, 'b' => 5), - array('a' => 20, 'b' => 10), - array('a' => 30, 'b' => 48), - ); + $original = [ + ['a' => 20, 'b' => 10], + ['a' => 45, 'b' => 5], + ['a' => 40, 'b' => 6], + ['a' => 30, 'b' => 48], + ]; + $expected = [ + ['a' => 45, 'b' => 5], + ['a' => 40, 'b' => 6], + ['a' => 20, 'b' => 10], + ['a' => 30, 'b' => 48], + ]; + + $this->assertEquals($expected, array_values(StandardFilters::sort($original, 'b'))); + $this->assertEquals($expected, array_values(StandardFilters::sort(new \ArrayIterator($original), 'b'))); + } - $this->assertEquals($expected, StandardFilters::sort($original, 'b'), '', 0, 10, true); + public function testSortWithoutKey() + { + // Sort by inner key + $original = [ + ['a' => 20, 'b' => 10], + ['a' => 45, 'b' => 5], + ['a' => 40, 'b' => 6], + ['a' => 30, 'b' => 48], + ['a' => 50], + ]; + $expected = [ + ['a' => 50], + ['a' => 45, 'b' => 5], + ['a' => 40, 'b' => 6], + ['a' => 20, 'b' => 10], + ['a' => 30, 'b' => 48], + ]; + + $this->assertEquals($expected, array_values(StandardFilters::sort($original, 'b'))); + $this->assertEquals($expected, array_values(StandardFilters::sort(new \ArrayIterator($original), 'b'))); } -/* - - I've commented this out as its not one of the Ruby Standard Filters - - public function testSortKey() { - $data = array( - array( - array(), - array(), - ), - array( - array('b' => 1, 'c' => 5, 'a' => 3, 'z' => 4, 'h' => 2), - array('a' => 3, 'b' => 1, 'c' => 5, 'h' => 2, 'z' => 4), - ), - ); + /* - foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::sort_key($item[0])); + I've commented this out as its not one of the Ruby Standard Filters + + public function testSortKey() { + $data = array( + array( + array(), + array(), + ), + array( + array('b' => 1, 'c' => 5, 'a' => 3, 'z' => 4, 'h' => 2), + array('a' => 3, 'b' => 1, 'c' => 5, 'h' => 2, 'z' => 4), + ), + ); + + foreach ($data as $item) { + $this->assertEquals($item[1], StandardFilters::sort_key($item[0])); + } } - } -*/ + */ - public function testDefault() { + public function testDefault() + { $this->assertEquals('hello', StandardFilters::_default('', 'hello')); $this->assertEquals('world', StandardFilters::_default('world', 'hello')); + // check that our workaround for 'default' works as it should + $this->assertTemplateResult('something', '{{ nothing | default: "something" }}'); } - - public function testUnique() { - $data = array( - array( - array(), - array(), - ), - array( - array(1, 1, 5, 3, 4, 2, 5, 2), - array(1, 5, 3, 4, 2), - ), - ); + + public function testUnique() + { + $data = [ + [ + [], + [], + ], + [ + new \ArrayIterator([]), + [], + ], + [ + [1, 1, 5, 3, 4, 2, 5, 2], + [1, 5, 3, 4, 2], + ], + [ + new \ArrayIterator([1, 1, 5, 3, 4, 2, 5, 2]), + [1, 5, 3, 4, 2], + ], + ]; foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::uniq($item[0]), '', 0, 10, true); + $this->assertEquals($item[1], array_values(StandardFilters::uniq($item[0]))); } } - public function testReverse() { - $data = array( - array( - array(), - array(), - ), - array( - array(1, 1, 5, 3, 4, 2, 5, 2), - array(2, 5, 2, 4, 3, 5, 1, 1), - ), - ); + public function testReverse() + { + $data = [ + [ + [], + [], + ], + [ + new \ArrayIterator([]), + [], + ], + [ + [1, 1, 5, 3, 4, 2, 5, 2], + [2, 5, 2, 4, 3, 5, 1, 1], + ], + [ + new \ArrayIterator([1, 1, 5, 3, 4, 2, 5, 2]), + [2, 5, 2, 4, 3, 5, 1, 1], + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::reverse($item[0]), '', 0, 10, true); } } - public function testMap() { - $data = array( - array( - array(), - '', - ), - array( - array( - function() { + public function testMap() + { + $data = [ + [ + [], + [], + ], + [ + new \ArrayIterator([]), + [], + ], + [ + [ + function () { return 'from function '; }, - array( + [ 'b' => 10, 'attr' => 'value ', - ), - array( + ], + [ 'a' => 20, - 'no_attr' => 'another value ' - ), - ), - 'from function value ', - ), - ); + 'no_attr' => 'another value ', + ], + ], + ['from function ', 'value ', null], + ], + [ + new \ArrayIterator([ + function () { + return 'from function '; + }, + [ + 'b' => 10, + 'attr' => 'value ', + ], + [ + 'a' => 20, + 'no_attr' => 'another value ', + ], + ]), + ['from function ', 'value ', null], + ], + [ + 0, + 0, + ], + ]; foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::map($item[0], 'attr')); + $actual = StandardFilters::map($item[0], 'attr'); + if ($actual instanceof \Traversable) { + $actual = iterator_to_array($actual); + } + $this->assertEquals($item[1], $actual); } } - public function testFirst() { - $data = array( - array( - array(), + public function testFirst() + { + $data = [ + [ + [], false, - ), - array( - array('two', 'one', 'three'), + ], + [ + new \ArrayIterator([]), + false, + ], + [ + ['two', 'one', 'three'], + 'two', + ], + [ + new \ArrayIterator(['two', 'one', 'three']), 'two', - ), - array( - array(100, 400, 200), + ], + [ + [100, 400, 200], + 100, + ], + [ + new \ArrayIterator([100, 400, 200]), 100, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::first($item[0])); } } - public function testLast() { - $data = array( - array( - array(), + public function testLast() + { + $data = [ + [ + [], + false, + ], + [ + new \ArrayIterator([]), false, - ), - array( - array('two', 'one', 'three'), + ], + [ + ['two', 'one', 'three'], 'three', - ), - array( - array(100, 400, 200), + ], + [ + new \ArrayIterator(['two', 'one', 'three']), + 'three', + ], + [ + [100, 400, 200], + 200, + ], + [ + new \ArrayIterator([100, 400, 200]), 200, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::last($item[0])); } } - public function testSplit() { - $data = array( - array( + public function testString() + { + $data = [ + [ + 1, + '1', + ], + [ + new SizeClass(), + "forty two", + ], + ]; + + foreach ($data as $item) { + $this->assertEquals($item[1], StandardFilters::string($item[0])); + } + } + + public function testSplit() + { + $data = [ + [ '', - array(0 => ''), - ), - array( + [], + ], + [ + null, + [], + ], + [ 'two-one-three', - array('two', 'one', 'three'), - ), - ); + ['two', 'one', 'three'], + ], + [ + '12301230123', + ['123', '123', '123'], + '0', + ], + [ + 'phrase', + ['p', 'h', 'r', 'a', 's', 'e'], + '', + ], + [ + 'phrase', + ['phrase'], + null, + ], + [ + '123 123 123', + ['123', '123', '123'], + ' ', + ], + ]; foreach ($data as $item) { - $this->assertEquals($item[1], StandardFilters::split($item[0], '-')); + $this->assertEquals($item[1], StandardFilters::split($item[0], $item[2] ?? '-')); } } - public function testStrip() { - $data = array( - array( + public function testStrip() + { + $data = [ + [ '', '', - ), - array( + ], + [ ' hello ', 'hello', - ), - array( + ], + [ 1, 1, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::strip($item[0])); } } - public function testLStrip() { - $data = array( - array( + public function testLStrip() + { + $data = [ + [ '', '', - ), - array( + ], + [ ' hello ', 'hello ', - ), - array( + ], + [ 1, 1, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::lstrip($item[0])); } } - public function testRStrip() { - $data = array( - array( + public function testRStrip() + { + $data = [ + [ '', '', - ), - array( + ], + [ ' hello ', ' hello', - ), - array( + ], + [ 1, 1, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertEquals($item[1], StandardFilters::rstrip($item[0])); } } - public function testPlus() { - $data = array( - array( + public function testPlus() + { + $data = [ + [ '', '', 0, - ), - array( + ], + [ 10, 20, 30, - ), - array( + ], + [ 1.5, 2.7, - 3, - ), - ); + 4.2, + ], + ]; foreach ($data as $item) { - $this->assertSame($item[2], StandardFilters::plus($item[0], $item[1])); + $this->assertEqualsWithDelta($item[2], StandardFilters::plus($item[0], $item[1]), 0.00001); } } - public function testMinus() { - $data = array( - array( + public function testMinus() + { + $data = [ + [ '', '', 0, - ), - array( + ], + [ 10, 20, -10, - ), - array( + ], + [ 1.5, 2.7, - -1, - ), - ); + -1.2, + ], + [ + 3.1, + 3.1, + 0, + ], + ]; foreach ($data as $item) { - $this->assertSame($item[2], StandardFilters::minus($item[0], $item[1])); + $this->assertEqualsWithDelta($item[2], StandardFilters::minus($item[0], $item[1]), 0.00001); } } - public function testTimes() { - $data = array( - array( + public function testTimes() + { + $data = [ + [ '', '', 0, - ), - array( + ], + [ 10, 20, 200, - ), - array( + ], + [ 1.5, 2.7, - 2, - ), - ); + 4.05, + ], + [ + 7.5, + 0, + 0, + ], + ]; foreach ($data as $item) { - $this->assertSame($item[2], StandardFilters::times($item[0], $item[1])); + $this->assertEqualsWithDelta($item[2], StandardFilters::times($item[0], $item[1]), 0.00001); } } - public function testDivideBy() { - $data = array( - array( + public function testDivideBy() + { + $data = [ + [ '20', 10, 2, - ), - array( + ], + [ 10, 20, 0.5, - ), - array( + ], + [ 0, 200, 0, - ), - ); + ], + [ + 10, + 0.5, + 20, + ], + ]; foreach ($data as $item) { - $this->assertSame($item[2], StandardFilters::divided_by($item[0], $item[1])); + $this->assertEqualsWithDelta($item[2], StandardFilters::divided_by($item[0], $item[1]), 0.00001); } } - public function testModulo() { - $data = array( - array( + public function testModulo() + { + $data = [ + [ '20', 10, 0, - ), - array( + ], + [ 10, 20, 10, - ), - array( + ], + [ 8, 3, 2, - ), - ); + ], + [ + 8.9, + 3.5, + 1.9, + ], + [ + 183.357, + 12, + 3.357, + ], + ]; foreach ($data as $item) { - $this->assertSame($item[2], StandardFilters::modulo($item[0], $item[1])); + $this->assertEqualsWithDelta($item[2], StandardFilters::modulo($item[0], $item[1]), 0.00001); } } - public function testRound() { - $data = array( - array( + public function testRound() + { + $data = [ + [ '20.003', 2, 20.00, - ), - array( + ], + [ 10, 3, 10.000, - ), - array( + ], + [ 8, 0, 8.0, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertSame($item[2], StandardFilters::round($item[0], $item[1])); } } - public function testCeil() { - $data = array( - array( + public function testCeil() + { + $data = [ + [ '20.003', 21, - ), - array( + ], + [ 10, 10, - ), - array( + ], + [ 0.42, 1, - ), - ); + ], + ]; foreach ($data as $item) { $this->assertSame($item[1], StandardFilters::ceil($item[0])); } } - public function testFloor() { - $data = array( - array( + public function testFloor() + { + $data = [ + [ '20.003', 20, - ), - array( + ], + [ 10, 10, - ), - array( + ], + [ 0.42, 0, - ), - ); + ], + [ + 2.5, + 2, + ], + ]; foreach ($data as $item) { $this->assertSame($item[1], StandardFilters::floor($item[0])); } } - public function testLocalFilter() { + public function testLocalFilter() + { $var = new Variable('var | money'); $this->context->set('var', 1000); $this->context->addFilters(new MoneyFilter()); $this->assertEquals(' 1000$ ', $var->render($this->context)); } - public function testUnderscoreInFilterName() { + public function testUnderscoreInFilterName() + { $var = new Variable('var | money_with_underscore '); $this->context->set('var', 1000); $this->context->addFilters(new MoneyFilter()); $this->assertEquals(' 1000$ ', $var->render($this->context)); } - public function testSecondFilterOverwritesFirst() { + public function testSecondFilterOverwritesFirst() + { $var = new Variable('var | money '); $this->context->set('var', 1000); - $this->context->addFilters(new MoneyFilter(), 'money'); - $this->context->addFilters(new CanadianMoneyFilter(), 'money'); + $this->context->addFilters(new MoneyFilter()); + $this->context->addFilters(new CanadianMoneyFilter()); $this->assertEquals(' 1000$ CAD ', $var->render($this->context)); } + + public function testDate() + { + $dateVar = '2017-07-01 21:00:00'; + + $var = new Variable('var | date, "%Y"'); + $this->context->set('var', $dateVar); + $this->assertEquals('2017', $var->render($this->context)); + + $var = new Variable("var | date: '%d/%m/%Y %l:%M %p'"); + $this->context->set('var', $dateVar); + $this->assertEquals('01/07/2017 9:00 PM', $var->render($this->context)); + + $var = new Variable('var | date, ""'); + $this->context->set('var', $dateVar); + $this->assertEquals($dateVar, $var->render($this->context)); + + $var = new Variable('var | date, "%Y-%m-%d %H:%M:%S"'); + $this->context->set('var', 1498942800); + $this->assertEquals($dateVar, $var->render($this->context)); + } } diff --git a/tests/Liquid/StaticAnalysisTest.php b/tests/Liquid/StaticAnalysisTest.php new file mode 100644 index 00000000..df070ad5 --- /dev/null +++ b/tests/Liquid/StaticAnalysisTest.php @@ -0,0 +1,107 @@ + $filename) { + $path = str_replace(getcwd(), '.', $filename); + + if (strpos($path, './vendor/') === 0) { + continue; + } + + $seenNonVendor = true; + yield $class => [str_replace(getcwd(), '.', $filename), $class]; + } + + if ($seenNonVendor === false) { + throw new \RuntimeException('Please generate the classmap.'); + } + } + + /** + * @dataProvider provideClasses + * @param mixed $filename + * @param mixed $class + */ + public function testClassExists($filename, $class) + { + $this->assertTrue(class_exists($class) || trait_exists($class) || interface_exists($class)); + } + + public static function provideAbstractTagSubclasses() + { + foreach (self::provideClasses() as $class => $data) { + if (!is_subclass_of($class, AbstractTag::class)) { + continue; + } + + $refClass = new ReflectionClass($class); + if (!$refClass->hasMethod('__construct')) { + continue; + } + + if ($refClass->getMethod('__construct')->getDeclaringClass()->getName() === AbstractTag::class) { + continue; // Skip if the constructor is not overridden + } + + yield $class => [...$data, $refClass]; + } + } + + /** + * @dataProvider provideAbstractTagSubclasses + * @param string $filename + * @param class-string $class + * @param ReflectionClass $refClass + */ + public function testAbstractTagChildCallsConstruct($filename, $class, ReflectionClass $refClass) + { + $refMethod = $refClass->getMethod('__construct'); + $startLine = $refMethod->getStartLine(); + $endLine = $refMethod->getEndLine(); + + $code = array_slice(file($refMethod->getFileName()), $startLine - 1, $endLine - $startLine + 1); + + $this->assertNotEmpty($code); + + $linesWithConstruct = array_filter($code, function ($line) { + return strpos($line, 'parent::__construct') !== false; + }); + + $this->assertNotEmpty( + $linesWithConstruct, + "The constructor of {$refClass->getName()} should call parent::__construct()" + ); + } +} diff --git a/tests/Liquid/Tag/NoTransformTest.php b/tests/Liquid/Tag/NoTransformTest.php index 65474b4a..7fe98b73 100644 --- a/tests/Liquid/Tag/NoTransformTest.php +++ b/tests/Liquid/Tag/NoTransformTest.php @@ -1,6 +1,6 @@ assertTemplateResult('this text should come out of the template without change...', - 'this text should come out of the template without change...'); + public function testNoTransform() + { + $this->assertTemplateResult( + 'this text should come out of the template without change...', + 'this text should come out of the template without change...' + ); $this->assertTemplateResult('blah', 'blah'); $this->assertTemplateResult('', ''); @@ -25,7 +28,7 @@ public function testNoTransform() { $this->assertTemplateResult('', ''); $text = "this shouldnt see any transformation either but has multiple lines - as you can clearly see here ..."; + as you can clearly see here ..."; $this->assertTemplateResult($text, $text); } diff --git a/tests/Liquid/Tag/TagAssignTest.php b/tests/Liquid/Tag/TagAssignTest.php index 705d056d..49c53ede 100644 --- a/tests/Liquid/Tag/TagAssignTest.php +++ b/tests/Liquid/Tag/TagAssignTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\Exception\ParseException::class); + $template = new Template(); $template->parse('{% assign test %}'); @@ -34,7 +36,8 @@ public function testInvalidAssign() { /** * Tests a simple assignment with no filters */ - public function testSimpleAssign() { + public function testSimpleAssign() + { $template = new Template(); $template->parse('{% assign test = "hello" %}{{ test }}'); @@ -44,7 +47,8 @@ public function testSimpleAssign() { /** * Tests filtered value assignment */ - public function testAssignWithFilters() { + public function testAssignWithFilters() + { $template = new Template(); $template->parse('{% assign test = "hello" | upcase %}{{ test }}'); @@ -54,15 +58,41 @@ public function testAssignWithFilters() { $this->assertTrue($template->render() === 'Hello'); $template->parse('{% assign test = var1 | first | upcase %}{{ test }}'); - $this->assertTrue($template->render(array('var1' => array('a', 'b', 'c'))) === 'A'); + $this->assertTrue($template->render(['var1' => ['a', 'b', 'c']]) === 'A'); $template->parse('{% assign test = var1 | last | upcase %}{{ test }}'); - $this->assertTrue($template->render(array('var1' => array('a', 'b', 'c'))) === 'C'); + $this->assertTrue($template->render(['var1' => ['a', 'b', 'c']]) === 'C'); $template->parse('{% assign test = var1 | join %}{{ test }}'); - $this->assertTrue($template->render(array('var1' => array('a', 'b', 'c'))) === 'a b c'); + $this->assertTrue($template->render(['var1' => ['a', 'b', 'c']]) === 'a b c'); $template->parse('{% assign test = var1 | join : "." %}{{ test }}'); - $this->assertTrue($template->render(array('var1' => array('a', 'b', 'c'))) === 'a.b.c'); + $this->assertTrue($template->render(['var1' => ['a', 'b', 'c']]) === 'a.b.c'); + } + + /** + * Tests filtered value assignment with separators + */ + public function testTagAssignWithSplit() + { + $template = new Template(); + + $template->parse('{% assign rows = "one|two|three,one|two|three" | upcase | split: "," %}{% for row in rows %}{% assign cols = row | split: "|" %}{% for col in cols %} {{col}}{%endfor%}{% endfor %}'); + $this->assertEquals($template->render(), ' ONE TWO THREE ONE TWO THREE'); + + $template->parse('{% assign issue_numbers = "1339|1338|1321" | split: "|" %}{% for issue in issue_numbers %} {{ issue }}{% endfor %}'); + $this->assertEquals($template->render(), ' 1339 1338 1321'); + } + + /** + * Tests a simple assignment with numbers + */ + public function testNumbersAssign() + { + $this->assertTemplateResult('42', '{% assign i = 42 %}{{ i }}'); + $this->assertTemplateResult('3.14', '{% assign i = 3.14 %}{{ i }}'); + $this->assertTemplateResult('-100', '{% assign i = -100 %}{{ i }}'); + $this->assertTemplateResult('-10.0', '{% assign i = -10.0 %}{{ i }}'); + $this->assertTemplateResult('-10.5', '{% assign i = -10.5 %}{{ i }}'); } } diff --git a/tests/Liquid/Tag/TagBlockTest.php b/tests/Liquid/Tag/TagBlockTest.php index 310a345f..70aba906 100644 --- a/tests/Liquid/Tag/TagBlockTest.php +++ b/tests/Liquid/Tag/TagBlockTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\Exception\ParseException::class); + $this->assertTemplateResult('', '{% block %}'); } - public function testCreateBlock() { + public function testCreateBlock() + { $this->assertTemplateResult('block content', '{% block foo %}block content{% endblock %}'); } } diff --git a/tests/Liquid/Tag/TagBreakTest.php b/tests/Liquid/Tag/TagBreakTest.php new file mode 100644 index 00000000..721a9c0f --- /dev/null +++ b/tests/Liquid/Tag/TagBreakTest.php @@ -0,0 +1,50 @@ +assertTemplateResult(' ', '{%for item in array%} {%break%} yo {%endfor%}', ['array' => [1, 2, 3, 4]]); + $this->assertTemplateResult(' yo ', '{%for item in array%} yo {%break%} {%endfor%}', ['array' => [1, 2, 3, 4]]); + $this->assertTemplateResult(' 1 2 ', '{%for item in array%} {%if item == 3%} {%break%} {%endif%} {{ item }} {%endfor%}', ['array' => [1, 2, 3, 4]]); + } + + public function testRange() + { + $this->assertTemplateResult(' ', '{%for item in (3..6)%} {%break%} yo {%endfor%}'); + $this->assertTemplateResult(' yo ', '{%for item in (3..6)%} yo {%break%} {%endfor%}'); + $this->assertTemplateResult(' 3 4 ', '{%for item in (3..6)%} {%if item == 5%} {%break%} {%endif%} {{ item }} {%endfor%}'); + } + + public function testTablerow() + { + $this->assertTemplateResult( + "\n\n", + '{%tablerow item in array%} {%break%} yo {%endtablerow%}', + ['array' => [1, 2, 3, 4]] + ); + $this->assertTemplateResult( + "\n yo \n", + '{%tablerow item in array%} yo {%break%} {%endtablerow%}', + ['array' => [1, 2, 3, 4]] + ); + $this->assertTemplateResult( + "\n 1 2 \n", + '{%tablerow item in array%} {%if item == 3%} {%break%} {%endif%} {{ item }} {%endtablerow%}', + ['array' => [1, 2, 3, 4]] + ); + } +} diff --git a/tests/Liquid/Tag/TagCaptureTest.php b/tests/Liquid/Tag/TagCaptureTest.php index 23cf337a..296777e5 100644 --- a/tests/Liquid/Tag/TagCaptureTest.php +++ b/tests/Liquid/Tag/TagCaptureTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\Exception\ParseException::class); + $template = new Template(); $template->parse("{% capture %} hello"); } - public function testCapture() { - $assigns = array('var' => 'content'); + public function testCapture() + { + $assigns = ['var' => 'content']; $this->assertTemplateResult('content foo content foo ', '{{ var2 }}{% capture var2 %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', $assigns); } } diff --git a/tests/Liquid/Tag/TagCaseTest.php b/tests/Liquid/Tag/TagCaseTest.php index d37db006..cb2e50d8 100644 --- a/tests/Liquid/Tag/TagCaseTest.php +++ b/tests/Liquid/Tag/TagCaseTest.php @@ -1,6 +1,6 @@ 2); + public function testCase() + { + $assigns = ['condition' => 2]; $this->assertTemplateResult(' its 2 ', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', $assigns); - $assigns = array('condition' => 1); + $assigns = ['condition' => 1]; $this->assertTemplateResult(' its 1 ', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', $assigns); - $assigns = array('condition' => 3); + $assigns = ['condition' => 3]; $this->assertTemplateResult('', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', $assigns); - $assigns = array('condition' => "string here"); + $assigns = ['condition' => "string here"]; $this->assertTemplateResult(' hit ', '{% case condition %}{% when "string here" %} hit {% endcase %}', $assigns); - $assigns = array('condition' => "bad string here"); + $assigns = ['condition' => "bad string here"]; $this->assertTemplateResult('', '{% case condition %}{% when "string here" %} hit {% endcase %}', $assigns); } - public function testCaseWithElse() { - $assigns = array('condition' => 5); + public function multipleConditionsProvider() + { + yield [[], '{% assign handle = "apple" %}{% case handle %}{% when "cake" %}This is a cake{% when "cookie", "biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is not a cake nor a cookie']; + yield [[], '{% assign handle = "cake" %}{% case handle %}{% when "cake" %}This is a cake{% when "cookie", "biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cake']; + yield [[], '{% assign handle = "cookie" %}{% case handle %}{% when "cake" %}This is a cake{% when "cookie", "biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cookie']; + yield [[], '{% assign handle = "cookie" %}{% case handle %}{% when "cake" %}This is a cake{% when "cookie" ,"biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cookie']; + yield [[], '{% assign handle = "cookie" %}{% case handle %}{% when "cake" %}This is a cake{% when "cookie" or "biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cookie']; + yield [[], '{% assign handle = "cookie" %}{% case handle %}{% when "cake" %}This is a cake{% when "cookie"or"biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cookie']; + yield [['condition' => 'cookie'], '{% assign handle = "cookie" %}{% case handle %}{% when "cake" %}This is a cake{% when condition, "biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cookie']; + yield [[], '{% assign handle = "cookie" %}{% assign condition = "cookie" %}{% case handle %}{% when "cake" %}This is a cake{% when condition, "biscuit" %}This is a cookie{% else %}This is not a cake nor a cookie{% endcase %}', 'This is a cookie']; + } + + /** + * @dataProvider multipleConditionsProvider + */ + public function testMultipleConditions(array $assigns, string $test, string $expected) + { + $this->assertTemplateResult($expected, $test, $assigns); + } + + public function testCaseWithElse() + { + $assigns = ['condition' => 5]; $this->assertTemplateResult(' hit ', '{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', $assigns); - $assigns = array('condition' => 6); + $assigns = ['condition' => 6]; $this->assertTemplateResult(' else ', '{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', $assigns); } + + /** + */ + public function testSyntaxErrorCase() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{% case %}{% when 5 %}{% endcase %}'); + } + + /** + */ + public function testSyntaxErrorWhen() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{% case condition %}{% when %}{% endcase %}'); + } + + /** + */ + public function testSyntaxErrorEnd() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{% case condition %}{% end %}'); + } + + /** + */ + public function testObject() + { + $this->expectException(\Liquid\Exception\RenderException::class); + + $this->assertTemplateResult('', '{% case variable %}{% when 5 %}{% endcase %}', ['variable' => (object) []]); + } + + public function testStringable() + { + $this->assertTemplateResult('hit', '{% case variable %}{% when 100 %}hit{% endcase %}', ['variable' => new Stringable()]); + } + + public function testToLiquid() + { + $this->assertTemplateResult('hit', '{% case variable %}{% when 100 %}hit{% endcase %}', ['variable' => new HasToLiquid()]); + } } diff --git a/tests/Liquid/Tag/TagCommentTest.php b/tests/Liquid/Tag/TagCommentTest.php index 650c40f7..b942cae0 100644 --- a/tests/Liquid/Tag/TagCommentTest.php +++ b/tests/Liquid/Tag/TagCommentTest.php @@ -1,6 +1,6 @@ assertTemplateResult("the comment block should be removed .. right?", - "the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?"); + public function testHasABlockWhichDoesNothing() + { + $this->assertTemplateResult( + "the comment block should be removed .. right?", + "the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?" + ); $this->assertTemplateResult('', '{%comment%}{%endcomment%}'); $this->assertTemplateResult('', '{%comment%}{% endcomment %}'); diff --git a/tests/Liquid/Tag/TagContinueTest.php b/tests/Liquid/Tag/TagContinueTest.php new file mode 100644 index 00000000..87457bcb --- /dev/null +++ b/tests/Liquid/Tag/TagContinueTest.php @@ -0,0 +1,50 @@ +assertTemplateResult(' ', '{%for item in array%} {%continue%} yo {%endfor%}', ['array' => [1, 2, 3, 4]]); + $this->assertTemplateResult(' yo yo yo yo ', '{%for item in array%} yo {%continue%} {%endfor%}', ['array' => [1, 2, 3, 4]]); + $this->assertTemplateResult(' 1 2 4 ', '{%for item in array%} {%if item == 3%} {%continue%} {%endif%} {{ item }} {%endfor%}', ['array' => [1, 2, 3, 4]]); + } + + public function testRange() + { + $this->assertTemplateResult(' ', '{%for item in (3..6)%} {%continue%} yo {%endfor%}'); + $this->assertTemplateResult(' yo yo yo yo ', '{%for item in (3..6)%} yo {%continue%} {%endfor%}'); + $this->assertTemplateResult(' 3 4 6 ', '{%for item in (3..6)%} {%if item == 5%} {%continue%} {%endif%} {{ item }} {%endfor%}'); + } + + public function testTablerow() + { + $this->assertTemplateResult( + "\n\n", + '{%tablerow item in array%} {%continue%} yo {%endtablerow%}', + ['array' => [1, 2, 3, 4]] + ); + $this->assertTemplateResult( + "\n yo yo yo yo \n", + '{%tablerow item in array%} yo {%continue%} {%endtablerow%}', + ['array' => [1, 2, 3, 4]] + ); + $this->assertTemplateResult( + "\n 1 2 4 \n", + '{%tablerow item in array%} {%if item == 3%} {%continue%} {%endif%} {{ item }} {%endtablerow%}', + ['array' => [1, 2, 3, 4]] + ); + } +} diff --git a/tests/Liquid/Tag/TagCycleTest.php b/tests/Liquid/Tag/TagCycleTest.php index d525b679..64a02859 100644 --- a/tests/Liquid/Tag/TagCycleTest.php +++ b/tests/Liquid/Tag/TagCycleTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\Exception\ParseException::class); + $template = new Template(); $template->parse("{% cycle %}"); } - public function testCycle() { + public function testCycle() + { $this->assertTemplateResult('one', '{%cycle "one", "two"%}'); $this->assertTemplateResult('one two', '{%cycle "one", "two"%} {%cycle "one", "two"%}'); $this->assertTemplateResult('one two one', '{%cycle "one", "two"%} {%cycle "one", "two"%} {%cycle "one", "two"%}'); } - public function testMultipleCycles() { + public function testMultipleCycles() + { $this->assertTemplateResult('1 2 1 1 2 3 1', '{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}'); } - public function testMultipleNamedCycles() { + public function testMultipleNamedCycles() + { $this->assertTemplateResult('one one two two one one', '{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}'); } - public function testMultipleNamedCyclesWithNamesFromContext() { - $assigns = array("var1" => 1, "var2" => 2); + public function testMultipleNamedCyclesWithNamesFromContext() + { + $assigns = ["var1" => 1, "var2" => 2]; $this->assertTemplateResult('one one two two one one', '{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', $assigns); } } diff --git a/tests/Liquid/Tag/TagDecrementTest.php b/tests/Liquid/Tag/TagDecrementTest.php index c270cf5d..dae8c8a7 100644 --- a/tests/Liquid/Tag/TagDecrementTest.php +++ b/tests/Liquid/Tag/TagDecrementTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\LiquidException::class); + $this->assertTemplateResult('', '{% decrement %}'); } /** * Undefined variable will become -1 */ - public function testDecrementNonExistingVariable() { + public function testDecrementNonExistingVariable() + { $this->assertTemplateResult(-1, '{% decrement no_such_var %}{{ no_such_var }}'); } - public function testDecrementVariable() { - $this->assertTemplateResult(42, '{% decrement var %}{{ var }}', array('var' => 43)); + public function testDecrementVariable() + { + $this->assertTemplateResult(42, '{% decrement var %}{{ var }}', ['var' => 43]); + } + + public function testDecrementNestedVariable() + { + $this->assertTemplateResult(42, '{% for var in vars %}{% decrement var %}{{ var }}{% endfor %}', ['vars' => [43]]); } - public function testDecrementNestedVariable() { - $this->assertTemplateResult(42, '{% for var in vars %}{% decrement var %}{{ var }}{% endfor %}', array('vars' => array(43))); + public function testVariableNameContainingNumber() + { + $this->assertTemplateResult(42, '{% decrement var123 %}{{ var123 }}', ['var123' => 43]); } } diff --git a/tests/Liquid/Tag/TagExtendsTest.php b/tests/Liquid/Tag/TagExtendsTest.php new file mode 100644 index 00000000..260b1c28 --- /dev/null +++ b/tests/Liquid/Tag/TagExtendsTest.php @@ -0,0 +1,215 @@ +fs = TestFileSystem::fromArray([ + 'base' => "{% block content %}{% endblock %}{% block footer %}{% endblock %}", + 'sub-base' => "{% extends 'base' %}{% block content %}{% endblock %}{% block footer %} Boo! {% endblock %}", + ]); + } + + protected function tearDown(): void + { + // PHP goes nuts unless we unset it + unset($this->fs); + } + + public function testBasicExtends() + { + $template = new Template(); + $template->setFileSystem($this->fs); + $template->parse("{% extends 'base' %}{% block content %}{{ hello }}{% endblock %}"); + $output = $template->render(["hello" => "Hello!"]); + $this->assertEquals("Hello!", $output); + } + + public function testDefaultContentExtends() + { + $template = new Template(); + $template->setFileSystem($this->fs); + $template->parse("{% block content %}{{ hello }}{% endblock %}\n{% extends 'sub-base' %}"); + $output = $template->render(["hello" => "Hello!"]); + $this->assertEquals("Hello!\n Boo! ", $output); + } + + public function testDeepExtends() + { + $template = new Template(); + $template->setFileSystem($this->fs); + $template->parse('{% extends "sub-base" %}{% block content %}{{ hello }}{% endblock %}{% block footer %} I am a footer.{% endblock %}'); + + $output = $template->render(["hello" => "Hello!"]); + $this->assertEquals("Hello! I am a footer.", $output); + } + + public function testWithCache() + { + $template = new Template(); + $template->setFileSystem($this->fs); + $template->setCache(new Local()); + + foreach (["Before cache", "With cache"] as $type) { + $template->parse("{% extends 'base' %}{% block content %}{{ hello }}{% endblock %}"); + $output = $template->render(["hello" => "$type"]); + $this->assertEquals($type, $output); + } + + $template->setCache(null); + } + + /** + * Render calls in this test will give different results (and fail the test) with cache enabled + */ + public function testExtendsReplaceContentWithCache() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'outer' => "{% block content %}Content for outer block{% endblock %} / {% block footer %}Footer for outer block{% endblock %}", + 'inner' => "{% extends 'outer' %}{% block content %}Content for inner block{% endblock %}", + ])); + + $contentsWithoutCache = $template->parseFile('inner')->render(); + + $template->setCache(new Local()); + $template->parseFile('outer'); + + $this->assertEquals($contentsWithoutCache, $template->parseFile('inner')->render()); + } + + public function testExtendsReplaceContentWithVariables() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'outer' => "{% block content %}Outer{{ a }}{% endblock %}Spacer{{ a }}{% block footer %}Footer{{ a }}{% endblock %}", + 'middle' => "{% extends 'outer' %}{% block content %}Middle{{ a }}{% endblock %}", + 'inner' => "{% extends 'middle' %}{% block content %}Inner{{ a }}{% endblock %}", + ])); + + $template->setCache(new Local()); + + $template->parseFile('outer')->render(['a' => '0']); + $template->parseFile('middle')->render(['a' => '1']); + $template->parseFile('middle')->render(['a' => '2']); + $this->assertEquals('Middle3Spacer3Footer3', $template->parseFile('middle')->render(['a' => '3'])); + $this->assertEquals('Inner4Spacer4Footer4', $template->parseFile('inner')->render(['a' => '4'])); + } + + public function testExtendsWithEmptyDefaultContent() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'base' => "
{% block content %}{% endblock %}
", + 'extends' => "{% extends 'base' %}{% block content %}{{ test }}{% endblock %}", + ])); + + $template->setCache(new Local()); + + $template->parseFile('base')->render(); + $template->parseFile('extends')->render(['test' => 'Foo']); + $template->parseFile('extends')->render(['test' => 'Bar']); + $this->assertEquals('
Baz
', $template->parseFile('extends')->render(['test' => 'Baz'])); + $this->assertEquals('
', $template->parseFile('base')->render()); + } + + public function testCacheDiscardedIfFileChanges() + { + $template = new Template(); + $template->setCache(new Local()); + + $content = "[{{ name }}]"; + $template->setFileSystem(TestFileSystem::fromArray([ + 'outer' => &$content, + 'inner' => "{% extends 'outer' %}", + ])); + + $template->parseFile('inner'); + $output = $template->render(["name" => "Example"]); + $this->assertEquals("[Example]", $output); + + // this should go from cache + $template->parse("{% extends 'outer' %}"); + $output = $template->render(["name" => "Example"]); + $this->assertEquals("[Example]", $output); + + // content change should trigger re-render + $content = "<{{ name }}>"; + $template->parseFile('inner'); + $output = $template->render(["name" => "Example"]); + $this->assertEquals("", $output); + } + + /** + */ + public function testInvalidSyntaxNoTemplateName() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $template = new Template(); + $template->parse("{% extends %}"); + } + + /** + */ + public function testInvalidSyntaxNotQuotedTemplateName() + { + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('Error in tag'); + + $template = new Template(); + $template->parse("{% extends base %}"); + } + + /** + */ + public function testMissingFilesystem() + { + $this->expectException(\Liquid\Exception\MissingFilesystemException::class); + $this->expectExceptionMessage('No file system'); + + $template = new Template(); + $template->parse("{% extends 'base' %}"); + } + + /** + */ + public function testInvalidSyntaxEmptyTemplateName() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $template = new Template(); + $template->setFileSystem($this->fs); + $template->parse("{% extends '' %}"); + } + + public function testInvalidSyntaxInvalidKeyword() + { + $template = new Template(); + $template->setFileSystem($this->fs); + $template->parse("{% extends 'base' nothing-should-be-here %}"); + + $this->markTestIncomplete("Exception is expected here"); + } +} diff --git a/tests/Liquid/Tag/TagForTest.php b/tests/Liquid/Tag/TagForTest.php index 5da06e70..588045f3 100644 --- a/tests/Liquid/Tag/TagForTest.php +++ b/tests/Liquid/Tag/TagForTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\Exception\ParseException::class); + $template = new Template(); $template->parse("{% for elem %}{% endfor %}"); } - public function testFor() { - $this->assertTemplateResult(' yo yo yo yo ', '{%for item in array%} yo {%endfor%}', array('array' => array(1, 2, 3, 4))); - $this->assertTemplateResult('yoyo', '{%for item in array%}yo{%endfor%}', array('array' => array(1, 2))); - $this->assertTemplateResult(' yo ', '{%for item in array%} yo {%endfor%}', array('array' => array(1))); - $this->assertTemplateResult('', '{%for item in array%}{%endfor%}', array('array' => array(1, 2))); + public function testFor() + { + $this->assertTemplateResult('', '{%for item in array%} yo {%endfor%}', ['array' => []]); + $this->assertTemplateResult(' yo yo yo yo ', '{%for item in array%} yo {%endfor%}', ['array' => [1, 2, 3, 4]]); + $this->assertTemplateResult(' boo boo boo boo ', '{%for item in array%} boo {%endfor%}', ['array' => new \ArrayIterator([1, 2, 3, 4])]); + $this->assertTemplateResult('yoyo', '{%for item in array%}yo{%endfor%}', ['array' => [1, 2]]); + $this->assertTemplateResult(' yo ', '{%for item in array%} yo {%endfor%}', ['array' => [1]]); + $this->assertTemplateResult('', '{%for item in array%}{%endfor%}', ['array' => [1, 2]]); $expected = <<assertTemplateResult($expected, $template, array('array' => array(1, 2, 3))); + $this->assertTemplateResult($expected, $template, ['array' => [1, 2, 3]]); } - public function testForWithVariable() { - $this->assertTemplateResult(' 1 2 3 ', '{%for item in array%} {{item}} {%endfor%}', array('array' => array(1, 2, 3))); - $this->assertTemplateResult('123', '{%for item in array%}{{item}}{%endfor%}', array('array' => array(1, 2, 3))); - $this->assertTemplateResult('123', '{% for item in array %}{{item}}{% endfor %}', array('array' => array(1, 2, 3))); - $this->assertTemplateResult('abcd', '{%for item in array%}{{item}}{%endfor%}', array('array' => array('a', 'b', 'c', 'd'))); - $this->assertTemplateResult('a b c', '{%for item in array%}{{item}}{%endfor%}', array('array' => array('a', ' ', 'b', ' ', 'c'))); - $this->assertTemplateResult('abc', '{%for item in array%}{{item}}{%endfor%}', array('array' => array('a', '', 'b', '', 'c'))); + public function testForWithVariable() + { + $this->assertTemplateResult(' 1 2 3 ', '{%for item in array%} {{item}} {%endfor%}', ['array' => [1, 2, 3]]); + $this->assertTemplateResult('123', '{%for item in array%}{{item}}{%endfor%}', ['array' => [1, 2, 3]]); + $this->assertTemplateResult('123', '{% for item in array %}{{item}}{% endfor %}', ['array' => [1, 2, 3]]); + $this->assertTemplateResult('abcd', '{%for item in array%}{{item}}{%endfor%}', ['array' => ['a', 'b', 'c', 'd']]); + $this->assertTemplateResult('a b c', '{%for item in array%}{{item}}{%endfor%}', ['array' => ['a', ' ', 'b', ' ', 'c']]); + $this->assertTemplateResult('abc', '{%for item in array%}{{item}}{%endfor%}', ['array' => ['a', '', 'b', '', 'c']]); + $this->assertTemplateResult(' a ', "{%\nfor item in array%} {{item}} {%endfor%}", ['array' => ['a']]); } - - public function testForWithHash() { - $this->assertTemplateResult('a=b c=d e=f ', '{%for item in array%}{{item[0]}}={{item[1]}} {%endfor%}', array('array' => array('a' => 'b', 'c' => 'd', 'e' => 'f'))); + + public function testForWithHash() + { + $this->assertTemplateResult('a=b c=d e=f ', '{%for item in array%}{{item[0]}}={{item[1]}} {%endfor%}', ['array' => ['a' => 'b', 'c' => 'd', 'e' => 'f']]); } - public function testForHelpers() { - $assigns = array('array' => array(1, 2, 3)); + public function testForHelpers() + { + $assigns = ['array' => [1, 2, 3]]; $this->assertTemplateResult(' 1/3 2/3 3/3 ', '{%for item in array%} {{forloop.index}}/{{forloop.length}} {%endfor%}', $assigns); $this->assertTemplateResult(' 1 2 3 ', '{%for item in array%} {{forloop.index}} {%endfor%}', $assigns); @@ -72,16 +81,32 @@ public function testForHelpers() { $this->assertTemplateResult(' 0 0 1 ', '{%for item in array%} {{forloop.last}} {%endfor%}', $assigns); } - public function testForAndIf() { - $assigns = array('array' => array(1, 2, 3)); + public function testForHelpersWithOffsetAndLimit() + { + $assigns = ['array' => [0, 1, 2, 3, 4]]; + + $this->assertTemplateResult(' 1/3 2/3 3/3 ', '{%for item in array offset:1 limit:3%} {{forloop.index}}/{{forloop.length}} {%endfor%}', $assigns); + $this->assertTemplateResult(' 1 2 3 ', '{%for item in array offset:1 limit:3%} {{forloop.index}} {%endfor%}', $assigns); + $this->assertTemplateResult(' 0 1 2 ', '{%for item in array offset:1 limit:3%} {{forloop.index0}} {%endfor%}', $assigns); + $this->assertTemplateResult(' 2 1 0 ', '{%for item in array offset:1 limit:3%} {{forloop.rindex0}} {%endfor%}', $assigns); + $this->assertTemplateResult(' 3 2 1 ', '{%for item in array offset:1 limit:3%} {{forloop.rindex}} {%endfor%}', $assigns); + $this->assertTemplateResult(' 1 0 0 ', '{%for item in array offset:1 limit:3%} {{forloop.first}} {%endfor%}', $assigns); + $this->assertTemplateResult(' 0 0 1 ', '{%for item in array offset:1 limit:3%} {{forloop.last}} {%endfor%}', $assigns); + } + + public function testForAndIf() + { + $assigns = ['array' => [1, 2, 3]]; $this->assertTemplateResult(' yay ', '{%for item in array%} {% if forloop.first %}yay{% endif %} {%endfor%}', $assigns); $this->assertTemplateResult(' yay boo boo ', '{%for item in array%} {% if forloop.first %}yay{% else %}boo{% endif %} {%endfor%}', $assigns); $this->assertTemplateResult(' boo boo ', '{%for item in array%} {% if forloop.first %}{% else %}boo{% endif %} {%endfor%}', $assigns); } - public function testLimiting() { - $assigns = array('array' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 0)); + public function testLimiting() + { + $assigns = ['array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]]; $this->assertTemplateResult('12', '{%for i in array limit:2 %}{{ i }}{%endfor%}', $assigns); + $this->assertTemplateResult('1234567890', '{%for i in array limit:20 %}{{ i }}{%endfor%}', $assigns); $this->assertTemplateResult('1234', '{%for i in array limit:4 %}{{ i }}{%endfor%}', $assigns); $this->assertTemplateResult('3456', '{%for i in array limit:4 offset:2 %}{{ i }}{%endfor%}', $assigns); $this->assertTemplateResult('3456', '{%for i in array limit: 4 offset: 2 %}{{ i }}{%endfor%}', $assigns); @@ -91,18 +116,21 @@ public function testLimiting() { $this->assertTemplateResult('34', '{%for i in array limit: limit offset: offset %}{{ i }}{%endfor%}', $assigns); } - public function testNestedFor() { - $assigns = array('array' => array(array(1, 2), array(3, 4), array(5, 6))); + public function testNestedFor() + { + $assigns = ['array' => [[1, 2], [3, 4], [5, 6]]]; $this->assertTemplateResult('123456', '{%for item in array%}{%for i in item%}{{ i }}{%endfor%}{%endfor%}', $assigns); } - public function testOffsetOnly() { - $assigns = array('array' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 0)); + public function testOffsetOnly() + { + $assigns = ['array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]]; $this->assertTemplateResult('890', '{%for i in array offset:7 %}{{ i }}{%endfor%}', $assigns); } - public function testPauseResume() { - $assigns = array('array' => array('items' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 0))); + public function testPauseResume() + { + $assigns = ['array' => ['items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]]]; $markup = <<assertTemplateResult($expected, $markup, $assigns); } - public function testPauseResumeLimit() { - $assigns = array('array' => array('items' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 0))); + public function testPauseResumeLimit() + { + $assigns = ['array' => ['items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]]]; $markup = <<assertTemplateResult($expected, $markup, $assigns); } - public function testPauseResumeBIGLimit() { - $assigns = array('array' => array('items' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 0))); + public function testPauseResumeBIGLimit() + { + $assigns = ['array' => ['items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]]]; $markup = <<assertTemplateResult($expected, $markup, $assigns); } - public function testPauseResumeBIGOffset() { - $assigns = array('array' => array('items' => array(1, 2, 3, 4, 5, 6, 7, 8, 9, 0))); + public function testPauseResumeBIGOffset() + { + $assigns = ['array' => ['items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]]]; $markup = <<assertTemplateResult($expected, $markup, $assigns); } + + public function testForWithRanges() + { + $this->assertTemplateResult('123456789', '{%for i in (1..9)%}{{i}}{%endfor%}'); + $this->assertTemplateResult(' 9 10 11', '{%for i in (9..11)%} {{i}}{%endfor%}'); + $this->assertTemplateResult('9991000', '{%for i in (999..1000)%}{{i}}{%endfor%}'); + + $assigns = ['variable' => 100]; + $this->assertTemplateResult('9596979899100', '{%for i in (95..variable)%}{{i}}{%endfor%}', $assigns); + } } diff --git a/tests/Liquid/Tag/TagIfTest.php b/tests/Liquid/Tag/TagIfTest.php index 74cc5c61..2b6a21fa 100644 --- a/tests/Liquid/Tag/TagIfTest.php +++ b/tests/Liquid/Tag/TagIfTest.php @@ -1,6 +1,6 @@ assertTemplateResult($expected, $text); } - public function testTrueNotEqlTrue() { + public function testTrueNotEqlTrue() + { $text = " {% if true != true %} true {% else %} false {% endif %} "; $expected = " false "; $this->assertTemplateResult($expected, $text); } - public function testTrueLqTrue() { + public function testTrueLqTrue() + { $text = " {% if 0 > 0 %} true {% else %} false {% endif %} "; $expected = " false "; $this->assertTemplateResult($expected, $text); } - public function testOneLqZero() { + public function testOneLqZero() + { $text = " {% if 1 > 0 %} true {% else %} false {% endif %} "; $expected = " true "; $this->assertTemplateResult($expected, $text); } - public function testZeroLqOne() { + public function testZeroLqOne() + { $text = " {% if 0 < 1 %} true {% else %} false {% endif %} "; $expected = " true "; $this->assertTemplateResult($expected, $text); } - public function testZeroLqOrEqualOne() { + public function testZeroLqOrEqualOne() + { $text = " {% if 0 <= 0 %} true {% else %} false {% endif %} "; $expected = " true "; $this->assertTemplateResult($expected, $text); } - public function testZeroLqOrEqualOneInvolvingNil() { + public function testZeroLqOrEqualOneInvolvingNil() + { $text = " {% if null <= 0 %} true {% else %} false {% endif %} "; $expected = " false "; $this->assertTemplateResult($expected, $text); @@ -62,109 +69,132 @@ public function testZeroLqOrEqualOneInvolvingNil() { $this->assertTemplateResult($expected, $text); } - public function testZeroLqqOrEqualOne() { + public function testZeroLqqOrEqualOne() + { $text = " {% if 0 >= 0 %} true {% else %} false {% endif %} "; $expected = " true "; $this->assertTemplateResult($expected, $text); } - public function testStrings() { + public function testStrings() + { $text = " {% if 'test' == 'test' %} true {% else %} false {% endif %} "; $expected = " true "; $this->assertTemplateResult($expected, $text); } - public function testStringsNotEqual() { + public function testStringsNotEqual() + { $text = " {% if 'test' != 'test' %} true {% else %} false {% endif %} "; $expected = " false "; $this->assertTemplateResult($expected, $text); } - public function testVarStringsEqual() { + public function testVarStringsEqual() + { $text = " {% if var == \"hello there!\" %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => 'hello there!')); + $this->assertTemplateResult($expected, $text, ['var' => 'hello there!']); } - public function testVarStringsAreNotEqual() { + public function testVarStringsAreNotEqual() + { $text = " {% if \"hello there!\" == var %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => 'hello there!')); + $this->assertTemplateResult($expected, $text, ['var' => 'hello there!']); } - public function testVarAndLongStringAreEqual() { + public function testVarAndLongStringAreEqual() + { $text = " {% if var == 'hello there!' %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => 'hello there!')); + $this->assertTemplateResult($expected, $text, ['var' => 'hello there!']); } - public function testVarAndLongStringAreEqualBackwards() { + public function testVarAndLongStringAreEqualBackwards() + { $text = " {% if 'hello there!' == var %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => 'hello there!')); + $this->assertTemplateResult($expected, $text, ['var' => 'hello there!']); } - public function testIsCollectionEmpty() { + public function testIsCollectionEmpty() + { $text = " {% if array == empty %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('array' => array())); + $this->assertTemplateResult($expected, $text, ['array' => []]); + + $text = " {% if empty == array %} true {% else %} false {% endif %} "; + $expected = " true "; + $this->assertTemplateResult($expected, $text, ['array' => []]); } - public function testIsNotCollectionEmpty() { + public function testIsNotCollectionEmpty() + { $text = " {% if array == empty %} true {% else %} false {% endif %} "; $expected = " false "; - $this->assertTemplateResult($expected, $text, array('array' => array(1, 2, 3))); + $this->assertTemplateResult($expected, $text, ['array' => [1, 2, 3]]); } - public function testNil() { + public function testNil() + { $text = " {% if var == null %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => null)); + $this->assertTemplateResult($expected, $text, ['var' => null]); $text = " {% if var == null %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => null)); + $this->assertTemplateResult($expected, $text, ['var' => null]); } - public function testNotNil() { + public function testNotNil() + { $text = " {% if var != null %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => 1)); + $this->assertTemplateResult($expected, $text, ['var' => 1]); $text = " {% if var != null %} true {% else %} false {% endif %} "; $expected = " true "; - $this->assertTemplateResult($expected, $text, array('var' => 1)); + $this->assertTemplateResult($expected, $text, ['var' => 1]); } - public function testIfFromVariable() { - $this->assertTemplateResult('', '{% if var %} NO {% endif %}', array('var' => false)); - $this->assertTemplateResult('', '{% if var %} NO {% endif %}', array('var' => null)); - $this->assertTemplateResult('', '{% if foo.bar %} NO {% endif %}', array('foo' => array('bar' => false))); - $this->assertTemplateResult('', '{% if foo.bar %} NO {% endif %}', array('foo' => array())); - $this->assertTemplateResult('', '{% if foo.bar %} NO {% endif %}', array('foo' => null)); + public function testNotNilWhitespaceControlEdgeCase() + { + $this->assertTemplateResult("true", "{% if 1 -%}true{% endif %}"); + $this->assertTemplateResult("true", "{% if 1 -%} true{% endif %}"); + } - $this->assertTemplateResult(' YES ', '{% if var %} YES {% endif %}', array('var' => "text")); - $this->assertTemplateResult(' YES ', '{% if var %} YES {% endif %}', array('var' => true)); - $this->assertTemplateResult(' YES ', '{% if var %} YES {% endif %}', array('var' => 1)); + public function testIfFromVariable() + { + $this->assertTemplateResult('', '{% if var %} NO {% endif %}', ['var' => false]); + $this->assertTemplateResult('', '{% if var %} NO {% endif %}', ['var' => null]); + $this->assertTemplateResult('', '{% if foo.bar %} NO {% endif %}', ['foo' => ['bar' => false]]); + $this->assertTemplateResult('', '{% if foo.bar %} NO {% endif %}', ['foo' => []]); + $this->assertTemplateResult('', '{% if foo.bar %} NO {% endif %}', ['foo' => null]); + + $this->assertTemplateResult(' YES ', '{% if var %} YES {% endif %}', ['var' => "text"]); + $this->assertTemplateResult(' YES ', '{% if var %} YES {% endif %}', ['var' => true]); + $this->assertTemplateResult(' YES ', '{% if var %} YES {% endif %}', ['var' => 1]); $this->assertTemplateResult(' YES ', '{% if "foo" %} YES {% endif %}'); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% endif %}', array('foo' => array('bar' => true))); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% endif %}', array('foo' => array('bar' => "text"))); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% endif %}', array('foo' => array('bar' => 1))); - - $this->assertTemplateResult(' YES ', '{% if var %} NO {% else %} YES {% endif %}', array('var' => false)); - $this->assertTemplateResult(' YES ', '{% if var %} NO {% else %} YES {% endif %}', array('var' => null)); - $this->assertTemplateResult(' YES ', '{% if var %} YES {% else %} NO {% endif %}', array('var' => true)); - $this->assertTemplateResult(' YES ', '{% if "foo" %} YES {% else %} NO {% endif %}', array('var' => "text")); - - $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', array('foo' => array('bar' => false))); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% else %} NO {% endif %}', array('foo' => array('bar' => true))); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% else %} NO {% endif %}', array('foo' => array('bar' => "text"))); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', array('foo' => array('notbar' => true))); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', array('foo' => array())); - $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', array('notfoo' => array('bar' => true))); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% endif %}', ['foo' => ['bar' => true]]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% endif %}', ['foo' => ['bar' => "text"]]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% endif %}', ['foo' => ['bar' => 1]]); + + $this->assertTemplateResult(' YES ', '{% if var %} NO {% else %} YES {% endif %}', ['var' => false]); + $this->assertTemplateResult(' YES ', '{% if var %} NO {% else %} YES {% endif %}', ['var' => null]); + $this->assertTemplateResult(' YES ', '{% if var %} YES {% else %} NO {% endif %}', ['var' => true]); + $this->assertTemplateResult(' YES ', '{% if "foo" %} YES {% else %} NO {% endif %}', ['var' => "text"]); + + $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', ['foo' => ['bar' => false]]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% else %} NO {% endif %}', ['foo' => ['bar' => true]]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} YES {% else %} NO {% endif %}', ['foo' => ['bar' => "text"]]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', ['foo' => ['notbar' => true]]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', ['foo' => []]); + $this->assertTemplateResult(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', ['notfoo' => ['bar' => true]]); } - public function testNestedIf() { + public function testNestedIf() + { $this->assertTemplateResult('', '{% if false %}{% if false %} NO {% endif %}{% endif %}'); $this->assertTemplateResult('', '{% if false %}{% if true %} NO {% endif %}{% endif %}'); $this->assertTemplateResult('', '{% if true %}{% if false %} NO {% endif %}{% endif %}'); @@ -175,33 +205,85 @@ public function testNestedIf() { $this->assertTemplateResult(' YES ', '{% if false %}{% if true %} NO {% else %} NONO {% endif %}{% else %} YES {% endif %}'); } - public function testComplexConditions() { + public function testComplexConditions() + { $this->assertTemplateResult('true', '{% if 10 == 10 and "h" == "h" %}true{% else %}false{% endif %}'); $this->assertTemplateResult('true', '{% if 8 == 10 or "h" == "h" %}true{% else %}false{% endif %}'); $this->assertTemplateResult('false', '{% if 8 == 10 and "h" == "h" %}true{% else %}false{% endif %}'); $this->assertTemplateResult('true', '{% if 10 == 10 or "h" == "k" or "k" == "k" %}true{% else %}false{% endif %}'); } - public function testContains() { - $this->assertTemplateResult('true', '{% if foo contains "h" %}true{% else %}false{% endif %}', array('foo' => array('k', 'h', 'z'))); - $this->assertTemplateResult('false', '{% if foo contains "y" %}true{% else %}false{% endif %}', array('foo' => array('k', 'h', 'z'))); - $this->assertTemplateResult('true', '{% if foo contains "e" %}true{% else %}false{% endif %}', array('foo' => 'abcedf')); - $this->assertTemplateResult('true', '{% if foo contains "e" %}true{% else %}false{% endif %}', array('foo' => 'e')); - $this->assertTemplateResult('false', '{% if foo contains "y" %}true{% else %}false{% endif %}', array('foo' => 'abcedf')); + public function testContains() + { + $this->assertTemplateResult('true', '{% if foo contains "h" %}true{% else %}false{% endif %}', ['foo' => ['k', 'h', 'z']]); + $this->assertTemplateResult('false', '{% if foo contains "y" %}true{% else %}false{% endif %}', ['foo' => ['k', 'h', 'z']]); + $this->assertTemplateResult('true', '{% if foo contains "e" %}true{% else %}false{% endif %}', ['foo' => 'abcedf']); + $this->assertTemplateResult('true', '{% if foo contains "e" %}true{% else %}false{% endif %}', ['foo' => 'e']); + $this->assertTemplateResult('false', '{% if foo contains "y" %}true{% else %}false{% endif %}', ['foo' => 'abcedf']); } /** - * @expectedException \Liquid\LiquidException - * @expectedExceptionMessage if tag was never closed */ - public function testSyntaxErrorNotClosed() { + public function testSyntaxErrorNotClosed() + { + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('if tag was never closed'); + $this->assertTemplateResult('', '{% if jerry == 1 %}'); } + public function testSyntaxErrorNotClosedLineBreak() + { + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('if tag was never closed'); + + $this->assertTemplateResult('', "{% if jerry\n == 1 %}"); + } + + /** + */ + public function testSyntaxErrorEnd() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{% if jerry == 1 %}{% end %}'); + } + + /** + */ + public function testInvalidOperator() + { + $this->expectException(\Liquid\Exception\RenderException::class); + + $this->assertTemplateResult('', '{% if foo === y %}true{% else %}false{% endif %}', ['foo' => true, 'y' => true]); + } + /** - * @expectedException \Liquid\LiquidException */ - public function testInvalidOperator() { - $this->assertTemplateResult('', '{% if foo === y %}true{% else %}false{% endif %}', array('foo' => true, 'y' => true)); + public function testIncomparable() + { + $this->expectException(\Liquid\Exception\RenderException::class); + + $this->assertTemplateResult('', '{% if foo == 1 %}true{% endif %}', ['foo' => (object) []]); + } + + /** + */ + public function testSyntaxErrorElse() + { + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('does not expect else tag'); + + $this->assertTemplateResult('', '{% if foo == 1 %}{% endif %}{% else %}'); + } + + /** + */ + public function testSyntaxErrorUnknown() + { + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('Unknown tag'); + + $this->assertTemplateResult('', '{% unknown-tag %}'); } } diff --git a/tests/Liquid/Tag/TagIfchangedTest.php b/tests/Liquid/Tag/TagIfchangedTest.php index 162074e6..5dcad58a 100644 --- a/tests/Liquid/Tag/TagIfchangedTest.php +++ b/tests/Liquid/Tag/TagIfchangedTest.php @@ -1,6 +1,6 @@ assertTemplateResult($expected, $text, array('array' => array(1, 2, 3))); + $this->assertTemplateResult($expected, $text, ['array' => [1, 2, 3]]); } - public function testFails() { + public function testFails() + { $text = "{% for i in array %}{% ifchanged %} {{ i }} {% endifchanged %}{% endfor %}"; $expected = " 1 2 1 "; - $this->assertTemplateResult($expected, $text, array('array' => array(1, 2, 2, 1))); + $this->assertTemplateResult($expected, $text, ['array' => [1, 2, 2, 1]]); } - } diff --git a/tests/Liquid/Tag/TagIncludeTest.php b/tests/Liquid/Tag/TagIncludeTest.php index 79ce7bd1..bb1d8229 100644 --- a/tests/Liquid/Tag/TagIncludeTest.php +++ b/tests/Liquid/Tag/TagIncludeTest.php @@ -1,6 +1,6 @@ fs = TestFileSystem::fromArray([ + 'a' => "{% include 'b' %}", + 'b' => "{% include 'c' %}", + 'c' => "{% include 'd' %}", + 'd' => '({{ inner }})', + 'inner' => "Inner: {{ inner }}{{ other }}", + 'example' => "Example: {% include 'inner' %}", + ]); + } + + protected function tearDown(): void + { + // PHP goes nuts unless we unset it + unset($this->fs); } -} -class TagIncludeTest extends TestCase -{ /** - * @expectedException \Liquid\LiquidException */ - public function testInvalidSyntaxNoTemplateName() { + public function testInvalidSyntaxNoTemplateName() + { + $this->expectException(\Liquid\Exception\ParseException::class); + $this->expectExceptionMessage('Error in tag'); + $template = new Template(); + $template->setFileSystem($this->fs); $template->parse("{% include %}"); } /** - * @expectedException \Liquid\LiquidException */ - public function testInvalidSyntaxNotQuotedTemplateName() { + public function testMissingFilesystem() + { + $this->expectException(\Liquid\Exception\MissingFilesystemException::class); + $this->expectExceptionMessage('No file system'); + $template = new Template(); - $template->parse("{% include hello %}"); + $template->parse("{% include 'hello' %}"); } - /** - * @expectedException \Liquid\LiquidException - */ - public function testInvalidSyntaxInvalidKeyword() { + public function testInvalidSyntaxInvalidKeyword() + { $template = new Template(); + $template->setFileSystem($this->fs); $template->parse("{% include 'hello' no_keyword %}"); + + $this->markTestIncomplete("Exception is expected here"); } - /** - * @expectedException \Liquid\LiquidException - */ - public function testInvalidSyntaxNoObjectCollection() { + public function testInvalidSyntaxNoObjectCollection() + { $template = new Template(); + $template->setFileSystem($this->fs); $template->parse("{% include 'hello' with %}"); + + $this->markTestIncomplete("Exception is expected here"); } - public function testIncludeTag() { + public function testIncludeTag() + { $template = new Template(); - $template->setFileSystem(new LiquidTestFileSystem()); + $template->setFileSystem($this->fs); $template->parse("Outer-{% include 'inner' with 'value' other:23 %}-Outer{% include 'inner' for var other:'loop' %}"); - $output = $template->render(array("var" => array(1, 2, 3))); + $output = $template->render(["var" => [1, 2, 3]]); $this->assertEquals("Outer-Inner: value23-OuterInner: 1loopInner: 2loopInner: 3loop", $output); } - public function testIncludeTagNoWith() { + public function testIncludeTagNoWith() + { $template = new Template(); - $template->setFileSystem(new LiquidTestFileSystem()); + $template->setFileSystem($this->fs); $template->parse("Outer-{% include 'inner' %}-Outer-{% include 'inner' other:'23' %}"); - $output = $template->render(array("inner" => "orig", "var" => array(1, 2, 3))); + $output = $template->render(["inner" => "orig", "var" => [1, 2, 3]]); $this->assertEquals("Outer-Inner: orig-Outer-Inner: orig23", $output); } + + /** + * @depends testInvalidSyntaxNoObjectCollection + */ + public function testWithCache() + { + $template = new Template(); + $template->setFileSystem($this->fs); + $template->setCache(new Local()); + + foreach (["Before cache:", "With cache:"] as $type) { + $template->parse("{{ type }} {% for item in list %}{% include 'example' inner:item %} {% endfor %}{% include 'a' %}"); + $template->render(["inner" => "foo", "list" => [1, 2, 3]], []); + $this->assertEquals("$type Example: Inner: 1 Example: Inner: 2 (bar)", $template->render(["type" => $type, "inner" => "bar", "list" => [1, 2]])); + } + + $template->setCache(null); + } + + public function testIncludeTemplateFile() + { + Liquid::set('INCLUDE_PREFIX', ''); + Liquid::set('INCLUDE_SUFFIX', 'tpl'); + + $template = new Template(dirname(__DIR__).DIRECTORY_SEPARATOR.self::TEMPLATES_DIR); + $template->parse("{% include 'mypartial' %}"); + // template include inserts a new line + $this->assertEquals("test content" . PHP_EOL, $template->render()); + } + + public function testIncludePassPlainValue() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'inner' => "[{{ other }}]", + 'example' => "({% include 'inner' other:var %})", + ])); + + $template->parse("{% include 'example' %}"); + + $output = $template->render(["var" => "test"]); + $this->assertEquals("([test])", $output); + } + + /** + */ + public function testIncludePassArrayWithoutIndex() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'inner' => "[{{ other }}]", + 'example' => "({% include 'inner' other:var %})", + ])); + + $template->parse("{% include 'example' %}"); + + $output = $template->render(["var" => ["a", "b", "c"]]); + $this->assertEquals("([abc])", $output); + } + + public function testIncludePassArrayWithIndex() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'inner' => "[{{ other[0] }}]", + 'example' => "({% include 'inner' other:var %})", + ])); + + $template->parse("{% include 'example' %}"); + + $output = $template->render(["var" => ["a", "b", "c"]]); + $this->assertEquals("([a])", $output); + } + + public function testIncludePassObjectValue() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'inner' => "[{{ other.a }}]", + 'example' => "({% include 'inner' other:var %})", + ])); + + $template->parse("{% include 'example' %}"); + + $output = $template->render(["var" => (object) ['a' => 'b']]); + $this->assertEquals("([b])", $output); + } + + public function testIncludeWithoutQuotes() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'inner' => "[{{ other }}]", + 'example' => "{%include inner other:var %} ({{var}})", + ])); + + $template->parse("{% include example other:var %}"); + + $output = $template->render(["var" => "test"]); + $this->assertEquals("[test] (test)", $output); + + $template->parse("{% include inner %}"); + + $output = $template->render(["other" => "test"]); + $this->assertEquals("[test]", $output); + } + + /** + * Render calls in this test shall give same results with cache enabled + */ + public function testIncludeWithExtends() + { + $template = new Template(); + $template->setFileSystem(TestFileSystem::fromArray([ + 'outer' => "{% block content %}Content for outer block{% endblock %} / {% block footer %}Footer for outer block{% endblock %}", + 'content' => 'Content for {{ name }} block', + 'middle' => "{% extends 'outer' %}{% block content %}{% include 'content' name:'middle' %}{% endblock %}", + 'main' => "Main: {% extends 'middle' %}{% block footer %}{% include 'footer-top' hello:message %}{% endblock %}", + 'footer-bottom' => "{{ name }} with message: {{ hello }}", + 'footer-top' => "Footer top and {% include 'footer-bottom' name:'bottom' %}", + ])); + + $template->setCache(new Local()); + + foreach (["Before cache", "With cache"] as $type) { + $this->assertEquals("Block with message: $type", $template->parseFile('footer-bottom')->render(["name" => "Block", "hello" => $type])); + $this->assertEquals('Content for middle block / Footer for outer block', $template->parseFile('middle')->render()); + $this->assertEquals("Main: Content for middle block / Footer top and bottom with message: $type", $template->parseFile('main')->render(["message" => $type])); + + $template->parse("{% include 'main' hello:message %}"); + $output = $template->render(["message" => $type]); + $this->assertEquals("Main: Content for middle block / Footer top and bottom with message: $type", $output); + } + + $template->setCache(null); + } + + public function testCacheDiscardedIfFileChanges() + { + $template = new Template(); + $template->setCache(new Local()); + + $content = "[{{ name }}]"; + $template->setFileSystem(TestFileSystem::fromArray([ + 'example' => &$content, + ])); + + $template->parse("{% include 'example' %}"); + $output = $template->render(["name" => "Example"]); + $this->assertEquals("[Example]", $output); + + $content = "<{{ name }}>"; + $template->parse("{% include 'example' %}"); + $output = $template->render(["name" => "Example"]); + $this->assertEquals("", $output); + } } diff --git a/tests/Liquid/Tag/TagIncrementTest.php b/tests/Liquid/Tag/TagIncrementTest.php index e39e1c95..8a90eb7b 100644 --- a/tests/Liquid/Tag/TagIncrementTest.php +++ b/tests/Liquid/Tag/TagIncrementTest.php @@ -1,6 +1,6 @@ expectException(\Liquid\Exception\ParseException::class); + $this->assertTemplateResult('', '{% increment %}'); } /** * Undefined variable will become 0 */ - public function testIncrementNonExistingVariable() { + public function testIncrementNonExistingVariable() + { $this->assertTemplateResult(0, '{% increment no_such_var %}{{ no_such_var }}'); } - public function testIncrementVariable() { - $this->assertTemplateResult(42, '{% increment var %}{{ var }}', array('var' => 41)); + public function testIncrementVariable() + { + $this->assertTemplateResult(42, '{% increment var %}{{ var }}', ['var' => 41]); } - public function testIncrementNestedVariable() { - $this->assertTemplateResult(42, '{% for var in vars %}{% increment var %}{{ var }}{% endfor %}', array('vars' => array(41))); + public function testIncrementNestedVariable() + { + $this->assertTemplateResult(42, '{% for var in vars %}{% increment var %}{{ var }}{% endfor %}', ['vars' => [41]]); } } diff --git a/tests/Liquid/Tag/TagPaginateTest.php b/tests/Liquid/Tag/TagPaginateTest.php index a473a665..af80f39b 100644 --- a/tests/Liquid/Tag/TagPaginateTest.php +++ b/tests/Liquid/Tag/TagPaginateTest.php @@ -1,6 +1,6 @@ assertTemplateResult($expected, $text, array('products' => array(array('id' => 1), array('id' => 2), array('id' => 3), array('id' => 4), array('id' => 5)))); + $this->assertTemplateResult($expected, $text, ['products' => [['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4], ['id' => 5]]]); + } + + public function testVariables() + { + $text = " {% paginate search.products by 3 %}{{ paginate.page_size }} {{ paginate.current_page }} {{ paginate.current_offset }} {{ paginate.pages }} {{ paginate.items }} {{ paginate.next.url }}{% endpaginate %}"; + $expected = " 3 1 0 2 5 http://?page=2"; + $this->assertTemplateResult($expected, $text, ['search' => ['products' => new \ArrayIterator([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4], ['id' => 5]])]]); + } + + public function testNextPage() + { + $text = '{% paginate products by 1 %}{% for product in products %} {{ product.id }} {% endfor %}{{ paginate.next.title }}{% endpaginate %}'; + $expected = ' 2 Next'; + $this->assertTemplateResult($expected, $text, ['HTTP_HOST' => 'example.com', 'REQUEST_URI' => '/products', 'HTTPS' => 'on', 'page' => 2, 'products' => [['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4], ['id' => 5]]]); + } + + /** + */ + public function testSyntaxErrorCase() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{% paginate products %}{% endpaginate %}'); + } + + /** + */ + public function testNoCollection() + { + $this->expectException(\Liquid\Exception\RenderException::class); + $this->expectExceptionMessage('Missing collection'); + + $this->assertTemplateResult('', '{% paginate products by 1 %}{% for product in products %}{{ product.id }}{% endfor %}{% endpaginate %}'); + } + + const PAGINATION_ASSIGNS = [ + 'HTTP_HOST' => 'example.com', + 'HTTPS' => 'on', + 'page' => 1, + 'articles' => [['title' => 1], ['title' => 2], ['title' => 3]], + ]; + + public function testPaginationForRepeatedCalls() + { + $text = '{% for article in articles %}{{ article.title }},{% endfor %}'; + $expected = '1,2,3,'; + $this->assertTemplateResult($expected, $text, self::PAGINATION_ASSIGNS); + + $text = '{% paginate articles by 2 %}{% for article in articles %}{{ article.title }},{% endfor %}{% endpaginate %} '.$text; + $expected = '1,2, 1,2,3,'; + $this->assertTemplateResult($expected, $text, self::PAGINATION_ASSIGNS); + } + + public function testPaginationDoesntIncludePreviousIfFirst() + { + $assigns = self::PAGINATION_ASSIGNS; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '1 , Next,https://example.com?page=2'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateDoesntIncludeNextIfLast() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['page'] = 3; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '3 Previous,https://example.com?page=2 ,'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateUsingDifferentRequestParameterName() + { + Liquid::set('PAGINATION_REQUEST_KEY', 'pagina'); + + $assigns = self::PAGINATION_ASSIGNS; + $assigns['page'] = 2; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '2 Previous,https://example.com?pagina=1 Next,https://example.com?pagina=3'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateUsingDifferentContextParameter() + { + Liquid::set('PAGINATION_CONTEXT_KEY', 'the_current_page'); + + $assigns = self::PAGINATION_ASSIGNS; + $assigns['the_current_page'] = 2; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '2 Previous,https://example.com?page=1 Next,https://example.com?page=3'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateUrlGenerationPreservesParams() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['REQUEST_URI'] = '/testfile.php?someparam=1'; + + $text = '{% paginate articles by 1 %}{{ paginate.next.url }}{% endpaginate %}'; + $expected = 'https://example.com/testfile.php?someparam=1&page=2'; + $this->assertTemplateResult($expected, $text, $assigns); } - - public function testVariables() { - $text = " {% paginate products by 3 %}{{ paginate.page_size }} {{ paginate.current_page }} {{ paginate.current_offset }} {{ paginate.pages }} {{ paginate.items }} {% endpaginate %}"; - $expected = " 3 1 0 2 5 "; - $this->assertTemplateResult($expected, $text, array('products' => array(array('id' => 1), array('id' => 2), array('id' => 3), array('id' => 4), array('id' => 5)))); + + public function testPaginateUrlGenerationReplacesPageKey() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['REQUEST_URI'] = '/testfile.php?someparam=1&page=1'; + + $text = '{% paginate articles by 1 %}{{ paginate.next.url }}{% endpaginate %}'; + $expected = 'https://example.com/testfile.php?someparam=1&page=2'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateUrlGenerationRespectsPageParameterKey() + { + Liquid::set('PAGINATION_REQUEST_KEY', 'pagina'); + + $assigns = self::PAGINATION_ASSIGNS; + $assigns['REQUEST_URI'] = '/testfile.php?someparam=1&page=hello&pagina=1'; + + $text = '{% paginate articles by 1 %}{{ paginate.next.url }}{% endpaginate %}'; + $expected = 'https://example.com/testfile.php?someparam=1&page=hello&pagina=2'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateUrlGenerationWithoutHTTPS() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['REQUEST_URI'] = '/'; + $assigns['HTTPS'] = ''; + + $text = '{% paginate articles by 1 %}{{ paginate.next.url }}{% endpaginate %}'; + $expected = 'http://example.com/?page=2'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateDoesntIncludeNextIfAfterLast() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['page'] = 42; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '3 Previous,https://example.com?page=2 ,'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateDoesntIncludePreviousIfBeforeFirst() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['page'] = 0; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '1 , Next,https://example.com?page=2'; + $this->assertTemplateResult($expected, $text, $assigns); + } + + public function testPaginateIgnoresNonNumbers() + { + $assigns = self::PAGINATION_ASSIGNS; + $assigns['page'] = 'foo'; + + $text = '{% paginate articles by 1 %}{% for article in articles %}{{article.title}}{% endfor %} {{paginate.previous.title}},{{paginate.previous.url}} {{paginate.next.title}},{{paginate.next.url}}{% endpaginate %}'; + $expected = '1 , Next,https://example.com?page=2'; + $this->assertTemplateResult($expected, $text, $assigns); } - } diff --git a/tests/Liquid/Tag/TagRawTest.php b/tests/Liquid/Tag/TagRawTest.php index fbcdc933..a79acf2f 100644 --- a/tests/Liquid/Tag/TagRawTest.php +++ b/tests/Liquid/Tag/TagRawTest.php @@ -1,6 +1,6 @@ assertTemplateResult( - '{{ y | plus: x }}{{{hello}}} is equal to 11.', - '{% raw %}{{ y | plus: x }}{{{hello}}}{% endraw %} is equal to 11.', array('x' => 5, 'y' => 6) + '{{ y | plus: x }}{% if %} is equal to 11.', + '{% raw %}{{ y | plus: x }}{% if %}{% endraw %} is equal to 11.', + ['x' => 5, 'y' => 6] ); + + $this->assertTemplateResult('', '{% raw %}{% endraw %}'); } } diff --git a/tests/Liquid/Tag/TagTablerowTest.php b/tests/Liquid/Tag/TagTablerowTest.php new file mode 100644 index 00000000..951e79c7 --- /dev/null +++ b/tests/Liquid/Tag/TagTablerowTest.php @@ -0,0 +1,62 @@ +assertTemplateResult( + ''."\n".' yo yo yo yo '."\n", + '{% tablerow item in array %} yo {% endtablerow %}', + ['array' => [1, 2, 3, 4]] + ); + + $this->assertTemplateResult( + '' . "\n" . ' item 1 ' . "\n" . '' . "\n" . ' item 2 ' . "\n", + '{% tablerow item in array cols:1 %} item {{ item }} {% endtablerow %}', + ['array' => [1, 2]] + ); + + $this->assertTemplateResult( + ''."\n".' 2 3 '."\n", + '{% tablerow item in array limit:2 offset:1 %} {{ item }} {% endtablerow %}', + ['array' => [1, 2, 3, 4]] + ); + + $this->assertTemplateResult( + ''."\n".' yo yo '."\n", + '{%tablerow item in array%} yo {%endtablerow%}', + ['array' => new \ArrayIterator([1, 2])] + ); + } + + /** + */ + public function testInvalidSyntax() + { + $this->expectException(\Liquid\Exception\ParseException::class); + + $this->assertTemplateResult('', '{%tablerow item array%} yo {%endtablerow%}', []); + } + + /** + */ + public function testNotArray() + { + $this->expectException(\Liquid\Exception\RenderException::class); + + $this->assertTemplateResult('', '{%tablerow item in array%} yo {%endtablerow%}', ['array' => true]); + } +} diff --git a/tests/Liquid/Tag/TagUnlessTest.php b/tests/Liquid/Tag/TagUnlessTest.php index ff4d4364..5f54156a 100644 --- a/tests/Liquid/Tag/TagUnlessTest.php +++ b/tests/Liquid/Tag/TagUnlessTest.php @@ -1,6 +1,6 @@ assertTemplateResult($expected, $text); } - public function testTrueNotEqlTrue() { + public function testTrueNotEqlTrue() + { $text = " {% unless true != true %} true {% else %} false {% endunless %} "; $expected = " true "; $this->assertTemplateResult($expected, $text); } -} \ No newline at end of file + + public function testWithVariable() + { + $text = " {% unless variable %} true {% else %} false {% endunless %} "; + $expected = " false "; + $this->assertTemplateResult($expected, $text, ['variable' => true]); + } + + public function testForAndUnless() + { + $this->assertTemplateResult('0=>yay 0=>yay 1=> ', '{% for item in array %}{{ forloop.last }}=>{% unless forloop.last %}yay{% endunless %} {% endfor %}', ['array' => [1, 2, 3]]); + $this->assertTemplateResult('1=> 0=>yay 0=>yay ', '{% for item in array %}{{ forloop.first }}=>{% unless forloop.first %}yay{% endunless %} {% endfor %}', ['array' => [1, 2, 3]]); + $this->assertTemplateResult('0=> 0=> 1=>yay ', '{% for item in array %}{{ forloop.last }}=>{% if forloop.last %}yay{% endif %} {% endfor %}', ['array' => [1, 2, 3]]); + } +} diff --git a/tests/Liquid/TemplateTest.php b/tests/Liquid/TemplateTest.php index 9a671d34..8a7cad3c 100644 --- a/tests/Liquid/TemplateTest.php +++ b/tests/Liquid/TemplateTest.php @@ -1,6 +1,6 @@ cacheDir = __DIR__ . DIRECTORY_SEPARATOR . self::CACHE_DIR; } - protected function tearDown() { + protected function tearDown(): void + { parent::tearDown(); // Remove tmp cache files @@ -32,121 +34,136 @@ protected function tearDown() { } /** - * @expectedException \Liquid\LiquidException */ - public function testSetCacheInvalidKey() { + public function testSetCacheInvalidKey() + { + $this->expectException(\Liquid\LiquidException::class); + $template = new Template(); - $template->setCache(array()); + $template->setCache([]); } /** - * @expectedException \Liquid\LiquidException */ - public function testSetCacheInvalidClass() { + public function testSetCacheInvalidClass() + { + $this->expectException(\Liquid\LiquidException::class); + $template = new Template(); - $template->setCache(array('cache' => 'no_such_class')); + $template->setCache(['cache' => 'no_such_class']); } - public function testSetCacheThroughArray() { + public function testSetCacheThroughArray() + { $template = new Template(); - $template->setCache(array('cache' => 'file', 'cache_dir' => $this->cacheDir)); - $this->assertInstanceOf('\Liquid\Cache\File', $template::getCache()); + $template->setCache(['cache' => 'file', 'cache_dir' => $this->cacheDir]); + $this->assertInstanceOf(\Liquid\Cache\File::class, $template::getCache()); } - public function testSetCacheThroughCacheObject() { + public function testSetCacheThroughCacheObject() + { $template = new Template(); - $cache = new Cache\File(array('cache_dir' => $this->cacheDir)); + $cache = new Cache\File(['cache_dir' => $this->cacheDir]); $template->setCache($cache); $this->assertEquals($cache, $template::getCache()); } - public function testTokenizeStrings() { - $this->assertEquals(array(' '), Template::tokenize(' ')); - $this->assertEquals(array('hello world'), Template::tokenize('hello world')); + public function testTokenizeStrings() + { + $this->assertEquals([' '], Template::tokenize(' ')); + $this->assertEquals(['hello world'], Template::tokenize('hello world')); } - public function testTokenizeVariables() { - $this->assertEquals(array('{{funk}}'), Template::tokenize('{{funk}}')); - $this->assertEquals(array(' ', '{{funk}}', ' '), Template::tokenize(' {{funk}} ')); - $this->assertEquals(array(' ', '{{funk}}', ' ', '{{so}}', ' ', '{{brother}}', ' '), Template::tokenize(' {{funk}} {{so}} {{brother}} ')); - $this->assertEquals(array(' ', '{{ funk }}', ' '), Template::tokenize(' {{ funk }} ')); + public function testTokenizeVariables() + { + $this->assertEquals(['{{funk}}'], Template::tokenize('{{funk}}')); + $this->assertEquals([' ', '{{funk}}', ' '], Template::tokenize(' {{funk}} ')); + $this->assertEquals([' ', '{{funk}}', ' ', '{{so}}', ' ', '{{brother}}', ' '], Template::tokenize(' {{funk}} {{so}} {{brother}} ')); + $this->assertEquals([' ', '{{ funk }}', ' '], Template::tokenize(' {{ funk }} ')); } - public function testTokenizeBlocks() { - $this->assertEquals(array('{%comment%}'), Template::tokenize('{%comment%}')); - $this->assertEquals(array(' ', '{%comment%}', ' '), Template::tokenize(' {%comment%} ')); - $this->assertEquals(array(' ', '{%comment%}', ' ', '{%endcomment%}', ' '), Template::tokenize(' {%comment%} {%endcomment%} ')); - $this->assertEquals(array(' ', '{% comment %}', ' ', '{% endcomment %}', ' '), Template::tokenize(" {% comment %} {% endcomment %} ")); + public function testTokenizeBlocks() + { + $this->assertEquals(['{%comment%}'], Template::tokenize('{%comment%}')); + $this->assertEquals([' ', '{%comment%}', ' '], Template::tokenize(' {%comment%} ')); + $this->assertEquals([' ', '{%comment%}', ' ', '{%endcomment%}', ' '], Template::tokenize(' {%comment%} {%endcomment%} ')); + $this->assertEquals([' ', '{% comment %}', ' ', '{% endcomment %}', ' '], Template::tokenize(" {% comment %} {% endcomment %} ")); } - public function testBlackspace() { + public function testBlackspace() + { $template = new Template(); $template->parse(' '); $nodelist = $template->getRoot()->getNodelist(); - $this->assertEquals(array(' '), $nodelist); + $this->assertEquals([' '], $nodelist); } - public function testVariableBeginning() { + public function testVariableBeginning() + { $template = new Template(); $template->parse('{{funk}} '); $nodelist = $template->getRoot()->getNodelist(); - $this->assertEquals(2, count($nodelist)); - $this->assertInstanceOf('\Liquid\Variable', $nodelist[0]); - $this->assertInternalType('string', $nodelist[1]); + $this->assertCount(2, $nodelist); + $this->assertInstanceOf(\Liquid\Variable::class, $nodelist[0]); + $this->assertIsString($nodelist[1]); } - public function testVariableEnd() { + public function testVariableEnd() + { $template = new Template(); $template->parse(' {{funk}}'); $nodelist = $template->getRoot()->getNodelist(); - $this->assertEquals(2, count($nodelist)); - $this->assertInternalType('string', $nodelist[0]); - $this->assertInstanceOf('\Liquid\Variable', $nodelist[1]); + $this->assertCount(2, $nodelist); + $this->assertIsString($nodelist[0]); + $this->assertInstanceOf(\Liquid\Variable::class, $nodelist[1]); } - public function testVariableMiddle() { + public function testVariableMiddle() + { $template = new Template(); $template->parse(' {{funk}} '); $nodelist = $template->getRoot()->getNodelist(); - $this->assertEquals(3, count($nodelist)); - $this->assertInternalType('string', $nodelist[0]); - $this->assertInstanceOf('\Liquid\Variable', $nodelist[1]); - $this->assertInternalType('string', $nodelist[2]); + $this->assertCount(3, $nodelist); + $this->assertIsString($nodelist[0]); + $this->assertInstanceOf(\Liquid\Variable::class, $nodelist[1]); + $this->assertIsString($nodelist[2]); } - public function testVariableManyEmbeddedFragments() { + public function testVariableManyEmbeddedFragments() + { $template = new Template(); $template->parse(' {{funk}} {{soul}} {{brother}} '); $nodelist = $template->getRoot()->getNodelist(); - $this->assertEquals(7, count($nodelist)); - $this->assertInternalType('string', $nodelist[0]); - $this->assertInstanceOf('\Liquid\Variable', $nodelist[1]); - $this->assertInternalType('string', $nodelist[2]); - $this->assertInstanceOf('\Liquid\Variable', $nodelist[3]); - $this->assertInternalType('string', $nodelist[4]); - $this->assertInstanceOf('\Liquid\Variable', $nodelist[5]); - $this->assertInternalType('string', $nodelist[6]); + $this->assertCount(7, $nodelist); + $this->assertIsString($nodelist[0]); + $this->assertInstanceOf(\Liquid\Variable::class, $nodelist[1]); + $this->assertIsString($nodelist[2]); + $this->assertInstanceOf(\Liquid\Variable::class, $nodelist[3]); + $this->assertIsString($nodelist[4]); + $this->assertInstanceOf(\Liquid\Variable::class, $nodelist[5]); + $this->assertIsString($nodelist[6]); } - public function testWithBlock() { + public function testWithBlock() + { $template = new Template(); $template->parse(' {% comment %} {% endcomment %} '); $nodelist = $template->getRoot()->getNodelist(); - $this->assertEquals(3, count($nodelist)); - $this->assertInternalType('string', $nodelist[0]); - $this->assertInstanceOf('\Liquid\Tag\TagComment', $nodelist[1]); - $this->assertInternalType('string', $nodelist[2]); + $this->assertCount(3, $nodelist); + $this->assertIsString($nodelist[0]); + $this->assertInstanceOf(\Liquid\Tag\TagComment::class, $nodelist[1]); + $this->assertIsString($nodelist[2]); } } diff --git a/tests/Liquid/TestCase.php b/tests/Liquid/TestCase.php index 682f251e..1b4bf176 100644 --- a/tests/Liquid/TestCase.php +++ b/tests/Liquid/TestCase.php @@ -1,6 +1,6 @@ 'field_exists', 'GET_PROPERTY_METHOD' => 'get', 'FILTER_SEPARATOR' => '\|', @@ -33,17 +34,17 @@ protected function setUp() { 'INCLUDE_ALLOW_EXT' => false, 'INCLUDE_SUFFIX' => 'liquid', 'INCLUDE_PREFIX' => '_', - 'TAG_START' => '{%', - 'TAG_END' => '%}', 'VARIABLE_START' => '{{', 'VARIABLE_END' => '}}', - 'ALLOWED_VARIABLE_CHARS' => '[a-zA-Z_.-]', - 'QUOTED_STRING' => '"[^":]*"|\'[^\':]*\'', - ); + 'VARIABLE_NAME' => '[a-zA-Z_][a-zA-Z0-9_.-]*', + 'EXPOSE_SERVER' => false, + ]; foreach ($defaultConfig as $configKey => $configValue) { Liquid::set($configKey, $configValue); } + + Template::setCache(null); } /** @@ -52,7 +53,8 @@ protected function setUp() { * @param array $assigns * @param string $message */ - public function assertTemplateResult($expected, $templateString, array $assigns = array(), $message = "%s") { + public function assertTemplateResult($expected, $templateString, array $assigns = [], $message = "%s") + { $template = new Template(); $template->parse($templateString); diff --git a/tests/Liquid/TestFileSystem.php b/tests/Liquid/TestFileSystem.php new file mode 100644 index 00000000..e38bba56 --- /dev/null +++ b/tests/Liquid/TestFileSystem.php @@ -0,0 +1,29 @@ +parse($source); + $template->setTickFunction(function (Context $context) use (&$ticks) { + $ticks++; + }); + + $template->render(); + + $this->assertGreaterThanOrEqual($min, $ticks); + $this->assertLessThanOrEqual($max, $ticks); + } +} diff --git a/tests/Liquid/VariableResolutionTest.php b/tests/Liquid/VariableResolutionTest.php index 2dc15b1b..064f7b14 100644 --- a/tests/Liquid/VariableResolutionTest.php +++ b/tests/Liquid/VariableResolutionTest.php @@ -1,6 +1,6 @@ parse("{{test}}"); - $this->assertEquals('worked', $template->render(array('test' => 'worked'))); + $this->assertEquals('worked', $template->render(['test' => 'worked'])); } - public function testSimpleWithWhitespaces() { + public function testSimpleWithWhitespaces() + { $template = new Template(); $template->parse(' {{ test }} '); - $this->assertEquals(' worked ', $template->render(array('test' => 'worked'))); - $this->assertEquals(' worked wonderfully ', $template->render(array('test' => 'worked wonderfully'))); + $this->assertEquals(' worked ', $template->render(['test' => 'worked'])); + $this->assertEquals(' worked wonderfully ', $template->render(['test' => 'worked wonderfully'])); } - public function testIgnoreUnknown() { + public function testIgnoreUnknown() + { $template = new Template(); $template->parse('{{ test }}'); $this->assertEquals('', $template->render()); } - public function testArrayScoping() { + public function testLineBreak() + { + $template = new Template(); + + $template->parse("{{ test |\n strip_html }}"); + $this->assertEquals('worked', $template->render(['test' => 'worked'])); + } + + public function testArrayScoping() + { $template = new Template(); $template->parse('{{ test.test }}'); - $this->assertEquals('worked', $template->render(array('test' => array('test' => 'worked')))); + $this->assertEquals('worked', $template->render(['test' => ['test' => 'worked']])); + } + + public function testVariableArrayIndices() + { + $template = new Template(); + + $template->parse("{% assign days = 'Mon,Tue,Wed,Thu,Fri,Sat,Sun' | split: ',' %}{% for i in (0..6) %}{{ days[i] }} {% endfor %}"); + $this->assertEquals('Mon Tue Wed Thu Fri Sat Sun ', $template->render()); } } diff --git a/tests/Liquid/VariableTest.php b/tests/Liquid/VariableTest.php index e76d7885..311c9c03 100644 --- a/tests/Liquid/VariableTest.php +++ b/tests/Liquid/VariableTest.php @@ -1,6 +1,6 @@ assertEquals('hello', $var->getName()); } - public function testFilters() { + public function testFilters() + { $var = new Variable('hello | textileze'); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('textileze', array())), $var->getFilters()); + $this->assertEquals([['textileze', []]], $var->getFilters()); $var = new Variable('hello | textileze | paragraph'); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('textileze', array()), array('paragraph', array())), $var->getFilters()); + $this->assertEquals([['textileze', []], ['paragraph', []]], $var->getFilters()); $var = new Variable(" hello | strftime: '%Y'"); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('strftime', array("'%Y'"))), $var->getFilters()); + $this->assertEquals([['strftime', ["'%Y'"]]], $var->getFilters()); $var = new Variable(" 'typo' | link_to: 'Typo', true "); $this->assertEquals("'typo'", $var->getName()); - $this->assertEquals(array(array('link_to', array("'Typo'", "true"))), $var->getFilters()); + $this->assertEquals([['link_to', ["'Typo'", "true"]]], $var->getFilters()); $var = new Variable(" 'typo' | link_to: 'Typo', false "); $this->assertEquals("'typo'", $var->getName()); - $this->assertEquals(array(array('link_to', array("'Typo'", "false"))), $var->getFilters()); + $this->assertEquals([['link_to', ["'Typo'", "false"]]], $var->getFilters()); $var = new Variable(" 'foo' | repeat: 3 "); $this->assertEquals("'foo'", $var->getName()); - $this->assertEquals(array(array('repeat', array("3"))), $var->getFilters()); + $this->assertEquals([['repeat', ["3"]]], $var->getFilters()); $var = new Variable(" 'foo' | repeat: 3, 3"); $this->assertEquals("'foo'", $var->getName()); - $this->assertEquals(array(array('repeat', array("3", "3"))), $var->getFilters()); + $this->assertEquals([['repeat', ["3", "3"]]], $var->getFilters()); $var = new Variable(" 'foo' | repeat: 3, 3, 3 "); $this->assertEquals("'foo'", $var->getName()); - $this->assertEquals(array(array('repeat', array("3", "3", "3"))), $var->getFilters()); + $this->assertEquals([['repeat', ["3", "3", "3"]]], $var->getFilters()); $var = new Variable(" hello | strftime: '%Y, okay?'"); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('strftime', array("'%Y, okay?'"))), $var->getFilters()); + $this->assertEquals([['strftime', ["'%Y, okay?'"]]], $var->getFilters()); $var = new Variable(" hello | things: \"%Y, okay?\", 'the other one'"); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('things', array('"%Y, okay?"', "'the other one'"))), $var->getFilters()); + $this->assertEquals([['things', ['"%Y, okay?"', "'the other one'"]]], $var->getFilters()); + + $var = new Variable(" product.featured_image | img_url: '450x450', crop: 'center', scale: 2 "); + $this->assertEquals("product.featured_image", $var->getName()); + $this->assertEquals([['img_url', ["'450x450'", ["crop" => "'center'", "scale" => "2"]]]], $var->getFilters()); } - public function testFiltersWithoutWhitespace() { + public function testFiltersWithoutWhitespace() + { $var = new Variable('hello | textileze | paragraph'); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('textileze', array()), array('paragraph', array())), $var->getFilters()); + $this->assertEquals([['textileze', []], ['paragraph', []]], $var->getFilters()); $var = new Variable('hello|textileze|paragraph'); $this->assertEquals('hello', $var->getName()); - $this->assertEquals(array(array('textileze', array()), array('paragraph', array())), $var->getFilters()); + $this->assertEquals([['textileze', []], ['paragraph', []]], $var->getFilters()); } - public function testSymbol() { + public function testSymbol() + { $var = new Variable("http://disney.com/logo.gif | image: 'med' "); $this->assertEquals('http://disney.com/logo.gif', $var->getName()); - $this->assertEquals(array(array('image', array("'med'"))), $var->getFilters()); + $this->assertEquals([['image', ["'med'"]]], $var->getFilters()); } - public function testStringSingleQuoted() { + public function testStringSingleQuoted() + { $var = new Variable(' "hello" '); $this->assertEquals('"hello"', $var->getName()); } - public function testStringDoubleQuoted() { + public function testStringDoubleQuoted() + { $var = new Variable(" 'hello' "); $this->assertEquals("'hello'", $var->getName()); } - public function testInteger() { + public function testInteger() + { $var = new Variable(' 1000 '); $this->assertEquals('1000', $var->getName()); } - public function testFloat() { + public function testFloat() + { $var = new Variable(' 1000.01 '); $this->assertEquals('1000.01', $var->getName()); } - public function testStringWithSpecialChars() { + public function testStringWithSpecialChars() + { $var = new Variable("'hello! $!@.;\"ddasd\" ' "); $this->assertEquals("'hello! $!@.;\"ddasd\" '", $var->getName()); } - public function testStringDot() { + public function testStringDot() + { $var = new Variable(" test.test "); $this->assertEquals('test.test', $var->getName()); } } - diff --git a/tests/Liquid/VirtualFileSystemTest.php b/tests/Liquid/VirtualFileSystemTest.php new file mode 100644 index 00000000..970625de --- /dev/null +++ b/tests/Liquid/VirtualFileSystemTest.php @@ -0,0 +1,86 @@ +expectException(\Liquid\LiquidException::class); + $this->expectExceptionMessage('Not a callback'); + + new Virtual(''); + } + + public function testReadTemplateFile() + { + $fs = new Virtual(function ($templatePath) { + if ($templatePath == 'foo') { + return "Contents of foo"; + } + + if ($templatePath == 'bar') { + return "Bar"; + } + + return ''; + }); + + $this->assertEquals('Contents of foo', $fs->readTemplateFile('foo')); + $this->assertEquals('Bar', $fs->readTemplateFile('bar')); + $this->assertEquals('', $fs->readTemplateFile('nothing')); + } + + /** + */ + public function testWithFileCache() + { + $this->expectException(\Liquid\LiquidException::class); + $this->expectExceptionMessage('cannot be used with a serializing cache'); + + $template = new Template(); + $template->setFileSystem(new Virtual(function ($templatePath) { + return ''; + })); + $template->setCache(new File([ + 'cache_dir' => __DIR__, + ])); + $template->parse("Hello"); + } + + public function virtualFileSystemCallback($templatePath) + { + return 'OK'; + } + + public function testWithRegularCallback() + { + $template = new Template(); + $template->setFileSystem(new Virtual([$this, 'virtualFileSystemCallback'], true)); + $template->setCache(new File([ + 'cache_dir' => __DIR__.'/cache_dir/', + ])); + + try { + $template->parse("Test: {% include 'hello' %}"); + } catch (\Throwable $e) { + $this->assertStringContainsString("Serialization of 'DOMDocument' is not allowed", $e->getMessage()); + $this->markTestIncomplete(); + } + $this->assertEquals('Test: OK', $template->render()); + } +} diff --git a/tests/Liquid/fixtures/assign-capture.html b/tests/Liquid/fixtures/assign-capture.html new file mode 100644 index 00000000..bc8b4132 --- /dev/null +++ b/tests/Liquid/fixtures/assign-capture.html @@ -0,0 +1,20 @@ + + +test 0 == test 0 + + + +test 1 == test 1 + + + +test 2 == test 2 + + + + + +test 3 == test 3 (inside an if) + + +test 3 == test 3 (outside of an if) \ No newline at end of file diff --git a/tests/Liquid/fixtures/assign-capture.liquid b/tests/Liquid/fixtures/assign-capture.liquid new file mode 100644 index 00000000..7d2bd24a --- /dev/null +++ b/tests/Liquid/fixtures/assign-capture.liquid @@ -0,0 +1,24 @@ +{% assign my_variable = 'test 0' %} + +{{ my_variable }} == test 0 + +{% capture my_variable %}test 1{% endcapture %} + +{{ my_variable }} == test 1 + +{% capture my_variable %}test 2{% endcapture %} + +{{ my_variable }} == test 2 + + +{% if true %} +{% assign my_variable = 'test 0' %} +{% capture my_variable %}test 3{% endcapture %} +{{ my_variable }} == test 3 (inside an if) +{% else %} +{% capture my_variable %}test 4{% endcapture %} +{{ my_variable }} + +{% endif %} + +{{ my_variable }} == test 3 (outside of an if) \ No newline at end of file diff --git a/tests/Liquid/fixtures/assign-capture.php b/tests/Liquid/fixtures/assign-capture.php new file mode 100644 index 00000000..9890e4eb --- /dev/null +++ b/tests/Liquid/fixtures/assign-capture.php @@ -0,0 +1,12 @@ +' %} +{{ descriptions[0] }} +{{ descriptions[1] }} diff --git a/tests/Liquid/fixtures/assign.php b/tests/Liquid/fixtures/assign.php new file mode 100644 index 00000000..38789dbc --- /dev/null +++ b/tests/Liquid/fixtures/assign.php @@ -0,0 +1,16 @@ + [ + 'description' => 'FirstSecond', + ], +]; diff --git a/tests/Liquid/fixtures/case.html b/tests/Liquid/fixtures/case.html new file mode 100644 index 00000000..27ecaad0 --- /dev/null +++ b/tests/Liquid/fixtures/case.html @@ -0,0 +1,31 @@ + + + hit 1 + + + + hit 2 or 3 + + + + hit 2 or 3 + + + + ... else ... + + + + ... else ... + + + + +Spring + +2019 + + +Spring + +2019 diff --git a/tests/Liquid/fixtures/case.liquid b/tests/Liquid/fixtures/case.liquid new file mode 100644 index 00000000..f366765d --- /dev/null +++ b/tests/Liquid/fixtures/case.liquid @@ -0,0 +1,38 @@ +{% for i in (1..max) %} + {% case i %} + {% when 1 %} + hit 1 + {% when 2 or 3 %} + hit 2 or 3 + {% else %} + ... else ... + {% endcase %} +{% endfor %} + +{% assign m = post.date | date: "%b" %} +{% case m %} + {% when 'April' %}Spring + {% when 'May' %}Spring + {% when 'June' %}Spring + {% when 'July' %}Summer + {% when 'August' %}Summer + {% when 'September' %}Summer + {% when 'October' %}Autumn + {% when 'November' %}Autumn + {% when 'December' %}Autumn + {% when 'January' %}Winter + {% when 'February' %}Winter + {% when 'March' %}Winter + {% else %}{{ post.date | date: "%b" }}. +{% endcase %} +{{ post.date | date: "%Y" }} + +{% assign m = post.date | date: "%b" %} +{% case m %} + {% when 'April' or 'May' or 'June' %}Spring + {% when 'July' or 'August' or 'September' %}Summer + {% when 'October' or 'November' or 'December' %}Autumn + {% when 'January' or 'February' or 'March' %}Winter + {% else %}{{ post.date | date: "%b" }}. +{% endcase %} +{{ post.date | date: "%Y" }} diff --git a/tests/Liquid/fixtures/case.php b/tests/Liquid/fixtures/case.php new file mode 100644 index 00000000..5846393e --- /dev/null +++ b/tests/Liquid/fixtures/case.php @@ -0,0 +1,15 @@ + 5, + 'post' => ['date' => 'May 28, 2019'], +]; diff --git a/tests/Liquid/fixtures/comment.html b/tests/Liquid/fixtures/comment.html new file mode 100644 index 00000000..9bdb7c0b --- /dev/null +++ b/tests/Liquid/fixtures/comment.html @@ -0,0 +1 @@ +We made 1 million dollars this year \ No newline at end of file diff --git a/tests/Liquid/fixtures/comment.liquid b/tests/Liquid/fixtures/comment.liquid new file mode 100644 index 00000000..83f8d6e7 --- /dev/null +++ b/tests/Liquid/fixtures/comment.liquid @@ -0,0 +1 @@ +We made 1 million dollars {% comment %} in losses {% endcomment %} this year \ No newline at end of file diff --git a/tests/Liquid/fixtures/comment.php b/tests/Liquid/fixtures/comment.php new file mode 100644 index 00000000..9890e4eb --- /dev/null +++ b/tests/Liquid/fixtures/comment.php @@ -0,0 +1,12 @@ + 5 +2 #=> 2 +0 #=> 0, not as is in the docs +2 #=> 2 +0 #=> 'foofoofoofoo' +20 #=> 20 +4 #=> 4 +4 #=> 4 +5 #=> 5 +<div>Read more</div> &rarr; +<div>Read more</div> → +Read more → +
Read more
→ +a%3Db%26c%3Dd +a=&b #=> 'a=&b' +Foo +[ Foo] +[Foo ] +OK +z +z, g, n +n +g-n-z +n-g-z +Foo
+Bar +FooBar +foobar +barbar #=> 'barbar' +foobar #=> 'foobar' +barbar #=> 'barbar' +bar #=> 'bar' +test... #=> 'test...' +Foo bar... #=> 'Foo bar...' +Foo #=> 'Foo' +Foo bar #=> 'Foo bar' + + + + + BazBar + + FooBar + diff --git a/tests/Liquid/fixtures/filters.liquid b/tests/Liquid/fixtures/filters.liquid new file mode 100644 index 00000000..7307cab4 --- /dev/null +++ b/tests/Liquid/fixtures/filters.liquid @@ -0,0 +1,50 @@ +Hello {{ 'tobi' | upcase }} +Hello tobi has {{ 'tobi' | size }} letters! +Array has {{ array | size }} elements. +Second element is: {{ array | slice: 2 | first }} +{{ '*tobi*' | textilize | upcase }} +{{ '1971-01-01' | date: "%Y %h" }} +{{ 'foo' | append:'bar' }} +{{ 'tobi' | capitalize }} +{{ 'Tobi' | downcase }} +{{ 10 | divided_by:2 }} #=> 5 +{{ 4 | minus:2 }} #=> 2 +{{ 'a' | plus:'b' }} #=> 0, not as is in the docs +{{ 1 | plus:1 }} #=> 2 +{{ 'foo' | times:4 }} #=> 'foofoofoofoo' +{{ 5 | times:4 }} #=> 20 +{{ 4.3 | round }} #=> 4 +{{ 4.8 | floor }} #=> 4 +{{ 4.3 | ceil }} #=> 5 +{{ '
Read more
→' | escape }} +{{ '
Read more
→' | escape_once }} +{{ '
Read more
→' | strip_html }} +{{ '
Read more
→' | raw }} +{{ 'a=b&c=d' | url_encode }} +{{ 'a%3D%26b' | url_decode }} #=> 'a=&b' +{{ ' Foo ' | strip }} +[{{ ' Foo ' | rstrip }}] +[{{ ' Foo ' | lstrip }}] +{{ '' | default: "OK" }} +{{ array | first }} +{{ array | join:', ' }} +{{ array | last }} +{{ array | sort | join:'-' }} +{{ array | reverse | join:'-' }} +{{ foobar | newline_to_br }} +{{ foobar | strip_newlines }} +{{ 'bar' | prepend:'foo' }} +{{ 'foofoo' | replace:'foo','bar' }} #=> 'barbar' +{{ 'barbar' | replace_first:'bar','foo' }} #=> 'foobar' +{{ 'foobarfoobar' | remove:'foo' }} #=> 'barbar' +{{ 'barbar' | remove_first:'bar' }} #=> 'bar' +{{ 'tests' | truncate:4 }} #=> 'test...' +{{ 'Foo bar bar' | truncatewords:2 }} #=> 'Foo bar...' +{{ 'Foo bar bar' | split: " " | first }} #=> 'Foo' +{{ 'Foo bar bar' | split: " " | uniq | join: " " }} #=> 'Foo bar' + + +{% assign titles = posts | sort: "id" | map: "title" %} +{% for title in titles %} + {{ title }} +{% endfor %} diff --git a/tests/Liquid/fixtures/filters.php b/tests/Liquid/fixtures/filters.php new file mode 100644 index 00000000..8053a1b4 --- /dev/null +++ b/tests/Liquid/fixtures/filters.php @@ -0,0 +1,19 @@ + ['z', 'g', 'n'], + 'foobar' => "Foo\nBar", + 'posts' => [ + ['id' => 2, 'title' => 'FooBar', 'tags' => ['foo', 'bar']], + ['id' => 1, 'title' => 'BazBar', 'tags' => ['baz', 'bar']], + ], +]; diff --git a/tests/Liquid/fixtures/for.html b/tests/Liquid/fixtures/for.html new file mode 100644 index 00000000..1852f6c5 --- /dev/null +++ b/tests/Liquid/fixtures/for.html @@ -0,0 +1,104 @@ +Helper variables: + + 1 => current item + 5 => length of the entire for loop + 1 => index of the current iteration + 0 => index of the current iteration (zero based) + 5 => how many items are still left? + 4 => how many items are still left? (zero based) + 1 => is this the first iteration? + 0 => is this the last iternation? + + 2 => current item + 5 => length of the entire for loop + 2 => index of the current iteration + 1 => index of the current iteration (zero based) + 4 => how many items are still left? + 3 => how many items are still left? (zero based) + 0 => is this the first iteration? + 0 => is this the last iternation? + + 3 => current item + 5 => length of the entire for loop + 3 => index of the current iteration + 2 => index of the current iteration (zero based) + 3 => how many items are still left? + 2 => how many items are still left? (zero based) + 0 => is this the first iteration? + 0 => is this the last iternation? + + 4 => current item + 5 => length of the entire for loop + 4 => index of the current iteration + 3 => index of the current iteration (zero based) + 2 => how many items are still left? + 1 => how many items are still left? (zero based) + 0 => is this the first iteration? + 0 => is this the last iternation? + + 5 => current item + 5 => length of the entire for loop + 5 => index of the current iteration + 4 => index of the current iteration (zero based) + 1 => how many items are still left? + 0 => how many items are still left? (zero based) + 0 => is this the first iteration? + 1 => is this the last iternation? + + +Limit and offset: + + 3 + + 4 + + +Reversing the loop: + + 1 + + 2 + + 3 + + 4 + + 5 + + +Range loop: + + 1 + + 2 + + 3 + + 4 + + 5 + + +Regular loop: + + 1 + + 2 + + 3 + + +Within a pagination: + + 1 + + 2 + + +Should not be affected by a pagination: + + 1 + + 2 + + 3 diff --git a/tests/Liquid/fixtures/for.liquid b/tests/Liquid/fixtures/for.liquid new file mode 100644 index 00000000..487b7e08 --- /dev/null +++ b/tests/Liquid/fixtures/for.liquid @@ -0,0 +1,41 @@ +Helper variables: +{% for item in array %} + {{ item }} => current item + {{ forloop.length }} => length of the entire for loop + {{ forloop.index }} => index of the current iteration + {{ forloop.index0 }} => index of the current iteration (zero based) + {{ forloop.rindex }} => how many items are still left? + {{ forloop.rindex0 }} => how many items are still left? (zero based) + {{ forloop.first }} => is this the first iteration? + {{ forloop.last }} => is this the last iternation? +{% endfor %} + +Limit and offset: +{% for item in array limit:2 offset:2 %} + {{ item }} +{% endfor %} + +Reversing the loop: +{% for item in array reversed %} + {{ item }} +{% endfor %} + +Range loop: +{% for i in (1..item.quantity) %} + {{ i }} +{% endfor %} + +Regular loop: +{% for article in articles %} + {{ article.title }} +{% endfor %} + +Within a pagination: +{% paginate articles by 2 %}{% for article in articles %} + {{ article.title }} +{% endfor %}{% endpaginate %} + +Should not be affected by a pagination: +{% for article in articles %} + {{ article.title }} +{% endfor %} \ No newline at end of file diff --git a/tests/Liquid/fixtures/for.php b/tests/Liquid/fixtures/for.php new file mode 100644 index 00000000..cd0efe23 --- /dev/null +++ b/tests/Liquid/fixtures/for.php @@ -0,0 +1,22 @@ + range(1, 5), + 'item' => [ + 'quantity' => 5, + ], + 'articles' => [ + ['title' => 1], + ['title' => 2], + ['title' => 3], + ], +]; diff --git a/tests/Liquid/fixtures/include.html b/tests/Liquid/fixtures/include.html new file mode 100644 index 00000000..686b9a0f --- /dev/null +++ b/tests/Liquid/fixtures/include.html @@ -0,0 +1,2 @@ +We made 1 million dollars this year +We made 1 million dollars this year \ No newline at end of file diff --git a/tests/Liquid/fixtures/include.liquid b/tests/Liquid/fixtures/include.liquid new file mode 100644 index 00000000..482422e2 --- /dev/null +++ b/tests/Liquid/fixtures/include.liquid @@ -0,0 +1,2 @@ +{% include comment.liquid %} +{% include comment.liquid %} \ No newline at end of file diff --git a/tests/Liquid/fixtures/include.php b/tests/Liquid/fixtures/include.php new file mode 100644 index 00000000..9890e4eb --- /dev/null +++ b/tests/Liquid/fixtures/include.php @@ -0,0 +1,12 @@ + call_user_func(function () { + yield 'a'; + yield 'b'; + yield 'c'; + }), +]; diff --git a/tests/Liquid/fixtures/output.html b/tests/Liquid/fixtures/output.html new file mode 100644 index 00000000..6de8f81f --- /dev/null +++ b/tests/Liquid/fixtures/output.html @@ -0,0 +1,4 @@ +Hello Harald +Hello DELACAP +Hello Superuser +Hello HARALD \ No newline at end of file diff --git a/tests/Liquid/fixtures/output.liquid b/tests/Liquid/fixtures/output.liquid new file mode 100644 index 00000000..1cad24ed --- /dev/null +++ b/tests/Liquid/fixtures/output.liquid @@ -0,0 +1,4 @@ +Hello {{ name }} +Hello {{ company }} +Hello {{ user.name }} +Hello {{ 'Harald' | upcase }} \ No newline at end of file diff --git a/tests/Liquid/fixtures/output.php b/tests/Liquid/fixtures/output.php new file mode 100644 index 00000000..4d4882e7 --- /dev/null +++ b/tests/Liquid/fixtures/output.php @@ -0,0 +1,18 @@ + 'Harald', + 'company' => 'DELACAP', + 'user' => [ + 'name' => 'Superuser', + ], +]; diff --git a/tests/Liquid/fixtures/whitespace-control.html b/tests/Liquid/fixtures/whitespace-control.html new file mode 100644 index 00000000..8629bdf7 --- /dev/null +++ b/tests/Liquid/fixtures/whitespace-control.html @@ -0,0 +1,7 @@ +-- + + + Wow, + John G. Chalmers-Smith, you have a long name! + +--Wow,John G. Chalmers-Smith, you have a long name!-- \ No newline at end of file diff --git a/tests/Liquid/fixtures/whitespace-control.liquid b/tests/Liquid/fixtures/whitespace-control.liquid new file mode 100644 index 00000000..f53ae1e2 --- /dev/null +++ b/tests/Liquid/fixtures/whitespace-control.liquid @@ -0,0 +1,17 @@ +-- +{% assign username = "John G. Chalmers-Smith" %} +{% if username and username.size > 10 %} + Wow, + {{ username }}, you have a long name! +{% else %} + Hello there! +{% endif %} +-- +{%- assign username = "John G. Chalmers-Smith" -%} +{%- if username and username.size > 10 -%} + Wow, + {{- username -}}, you have a long name! +{%- else -%} + Hello there! +{%- endif -%} +-- \ No newline at end of file diff --git a/tests/Liquid/fixtures/whitespace-control.php b/tests/Liquid/fixtures/whitespace-control.php new file mode 100644 index 00000000..9890e4eb --- /dev/null +++ b/tests/Liquid/fixtures/whitespace-control.php @@ -0,0 +1,12 @@ +