Compare commits

...

106 Commits

Author SHA1 Message Date
Simon Génier
a61903da54 Add support for iterating ranges with float boundaries.
The meaning I gave is to start at the lower bound and step by one. This has the
advantage of having (n..m) and ((n.to_f)..(m.to_f)) behave the same. Another
thing we could do that have this same property is to cast bounds to integer.
This would do the right thing if there is a floating point rounding error, but I
feel it is more surprising for cases such as (1.5..8).

Note that it is not possible to create ranges with floating point boundaries
directly in Liquid at the moment. However, since there is no distinction between
Ruby and Liquid ranges, a drop can introduce such a value by returning it.
2017-08-09 12:13:00 -04:00
Rene
59162f7a0e added attr_readers for collection and variable names in for tag (#909) 2017-07-06 09:41:48 -04:00
Thierry Joyal
c582b86f16 Merge pull request #898 from Shopify/cgi-powered-standard-filters-to-handle-non-string-inputs
CGI powered standard filters to handle non string inputs
2017-05-26 18:05:42 +00:00
Thierry Joyal
e340803d12 CGI powered standard filters to handle non string inputs 2017-05-25 15:53:41 +00:00
Dylan Thacker-Smith
48a6d86ac2 Use stackprof to test to lack of object allocations (#896) 2017-05-12 09:20:51 -04:00
Dylan Thacker-Smith
3bb29d5456 Replace assert_equal nil, with a assert_nil (#895) 2017-05-11 14:05:03 -04:00
Dylan Thacker-Smith
9c72ccb82f Limit how much blocks can be nested during parsing (#894) 2017-05-11 09:37:53 -04:00
Dylan Thacker-Smith
62d4625468 Use a loop to strictly parse binary comparisons to avoid recursion (#892)
Using recursion allows a malicious template to cause a SystemStackError
2017-05-10 10:41:52 -04:00
Dylan Thacker-Smith
8928454e29 Use a loop to evaluate binary comparisions to avoid recursion (#891)
Using recursion allows a malicious template to cause a SystemStackError
2017-05-10 10:41:24 -04:00
Florian Weingarten
1370a102c9 Merge pull request #789 from evulse/contains-strict-fix
Allow variables to start with contains in strict parser
2017-03-24 09:50:31 -04:00
Mike Angell
c9bac9befe Merge branch 'master' into contains-strict-fix 2017-03-24 11:09:09 +10:00
Mike
210a0616f3 Update History to include fix 2017-03-24 10:35:56 +10:00
Lasse Skindstad Ebert
5149cde5c3 Fix include tag used with strict_variables (#829)
Fixes https://github.com/Shopify/liquid/issues/828
2017-03-22 16:00:31 -04:00
Florian Weingarten
22f2cec5de Merge pull request #864 from chenxianyu2015/fix-strainer-add_filter-method
fix  #861: duplicate inclusion condition logic error of Liquid::Strainer.add_filter method
2017-02-23 14:06:20 -05:00
chenxianyu
4318240ae0 test: modify Strainer.add_filter duplicate inclusion test case 2017-02-22 10:33:22 +08:00
chenxianyu
aa79c33dda fix: Strainer.add_filter method 2017-02-13 15:50:19 +08:00
Justin Li
b1ef28566e Merge pull request #846 from mrmanc/master
Clarifies spelling of for’s reversed flag to address #843
2017-02-10 19:26:38 -05:00
Justin Li
41bcc48222 Merge pull request #854 from jaredbeck/patch-1
Docs: Help people upgrade to 4, re: liquid_methods
2017-02-10 19:25:04 -05:00
Dylan Thacker-Smith
27d5106dc9 Merge pull request #860 from Shopify/handle-string-node-render-exc
Avoid calling line_number on String node when rescuing a render error.
2017-02-10 14:13:11 -05:00
Dylan Thacker-Smith
7334073be2 Avoid duck typing to detect whether to call render on a node. 2017-02-10 13:49:26 -05:00
Dylan Thacker-Smith
5dcefd7d77 Avoid calling line_number on String node when rescuing a render error. 2017-02-07 15:34:10 -05:00
Richard Monette
25c7b05916 Merge pull request #857 from Shopify/handle-join-on-fixnum
handle join on fixnum
2017-02-01 14:25:40 -05:00
Richard Monette
d17f86ba4d handle join on fixnum 2017-02-01 12:47:35 -05:00
Jerry Liu
384e4313ff Merge pull request #851 from Shopify/benchmark-render
Allow benchmarks to benchmark render by itself
2017-01-31 17:18:56 -05:00
Jerry Liu
23f2af8ff5 fix travis build 2017-01-31 17:04:36 -05:00
Jerry Liu
a93eac0268 Introduce new benchmarking methods to liquid to use on rubybench 2017-01-27 10:56:16 -05:00
Florian Weingarten
2cc7493cb0 Merge pull request #855 from Shopify/bundler-benchmark-group
Create a benchmark group in Gemfile
2017-01-20 16:41:11 -05:00
Jerry Liu
85463e1753 add benchmark-ips to benchmark group in Gemfile 2017-01-20 16:04:42 -05:00
Jared Beck
52ff9b0e84 Docs: Help people upgrade to 4, re: liquid_methods
The discussion in #568 helped me.

[ci skip]
2017-01-19 14:23:39 -05:00
Dylan Thacker-Smith
0c58328a40 test: Equality comparison of two hashes (#850) 2017-01-16 15:56:38 -05:00
Dylan Thacker-Smith
2bb3552033 Fix internal liquid error when comparing hash with incompatible type (#849) 2017-01-16 13:13:17 -05:00
Mark Crossfield
8b751ddf46 Removes a non ascii character from comment to appease Rubocop 2017-01-09 10:16:35 +00:00
Mark Crossfield
e5cbdb2b27 Clarifies spelling of for’s reversed flag to address #843
It should now be harder to read the docs and miss the extra letter required for reversed compared to reverse, which causes a fairly generic syntax warning when trying to reverse sort a collection in a for loop.
2017-01-08 12:44:12 +00:00
Justin Li
ffb0ace303 Update changelog for 4.0.0 2016-12-16 13:11:22 -05:00
Florian Weingarten
ad00998ef8 bump to v4 2016-12-14 11:58:42 -05:00
Dylan Thacker-Smith
869dbc7ebf feature: Allow a default exception renderer to be specified (#837)
This could be used to preserve the old default of rendering
non-Liquid::Error messages or for providing default behaviour like error
reporting which could be missed if the exception renderer needed to be
specified on each render.
2016-12-12 10:29:09 -05:00
Dylan Thacker-Smith
fae3a2de7b Add version constraint to rake to fix CI (#836) 2016-12-09 14:01:15 -05:00
Dylan Thacker-Smith
f27bd619b9 change: Render an opaque internal error by default for non-Liquid::Error (#835)
These errors may contain sensitive information, so is safer to
render a more vague message by default.

This is done by replacing non-Liquid::Error exceptions with a
Liquid::InternalError exception with the non-Liquid::Error accessible on
through the cause method. This also allows the template name and line
number to be attached to the template errors.

The exception_handler render option has been changed to exception_renderer
since now it should raise an exception to re-raise on a liquid rendering
error or return a string to be rendered where the error occurred.
2016-12-07 17:34:29 -05:00
Dylan Thacker-Smith
a9b84b7806 test: Use ruby 2.1 in Circle CI 2016-12-05 15:36:42 -05:00
Dylan Thacker-Smith
6cc2c567c5 Merge pull request #832 from Shopify/drop-ruby-2.0
Drop support for ruby 2.0
2016-12-05 13:56:15 -05:00
Dylan Thacker-Smith
812e3c51b9 test: Add ruby 2.3.3 to CI
Travis doesn't have a ruby 2.3 alias, so the latest 2.3.x version is
specified instead.
2016-12-05 13:53:02 -05:00
Dylan Thacker-Smith
9dd0801f5c Drop support for ruby 2.0
It is no longer maintained upstream
2016-12-05 13:51:49 -05:00
Dylan Thacker-Smith
b146b49f46 fix: Clear the strainer cache when a global filter is added (#826) 2016-11-24 10:32:11 -05:00
Richard Monette
86944fe7b7 Merge pull request #809 from Shopify/introduce-unhandled-liquid-exception
introduce unhandled liquid exception
2016-10-31 10:20:06 -04:00
Richard Monette
a549d289d7 introduce unhandled liquid exception
check arity
2016-10-28 09:40:44 -04:00
Richard Monette
b2feeacbce Merge pull request #812 from Shopify/allow-split-to-accept-numeric
allow split to accept numeric
2016-10-26 10:59:44 -04:00
Richard Monette
143ba39a08 allow split to accept numeric 2016-10-26 10:43:04 -04:00
Richard Monette
43e59796f6 Merge pull request #805 from Shopify/dont-explode-when-sorting-nil-property
dont explode when sorting nil property
2016-10-05 10:18:16 -04:00
Richard Monette
bb3624b799 dont explode when sorting nil property 2016-10-04 13:22:29 -04:00
Konstantin Tennhard
64fca66ef5 Merge pull request #797 from Shopify/truncatewords-resiliency
Standard filter truncate / truncatewords: force truncate_string to string
2016-09-13 10:43:55 -04:00
Florian Weingarten
e9d7486758 4.0.0.rc3 2016-09-13 06:33:20 -04:00
Philibert Dugas
2bb98c1431 Merge pull request #798 from PhilibertDugas/bugfix-#697
Fixing #697 with better exception
2016-09-12 13:53:22 -04:00
Konstantin Tennhard
95d5c24bfc Standard filter truncate: truncate_string string coercion
The argument `truncate_string` is now coerced into a string to avoid
`NoMethodError`s. This is mostly for added resiliency. It is doubtful
that someone would actually intent to use a number as truncate string,
but accidentally supplying one is entirely possible.
2016-09-12 12:13:12 -04:00
Philibert Dugas
b7ee1a2176 Fixing #697 with better exception
When including a template which is not defined, the exception raised is
*undefined method `split` for nil:NilClass*

This occurs for a scenario like the following:
`{% include nil %}`
or
`{% include undefined-var %}`

Making the code raise an argument error to allow better understanding of
the include error
2016-09-12 09:31:59 -04:00
Florian Weingarten
0eca61a977 Merge pull request #799 from kainjow/patch-1
Update liquid-c
2016-09-12 08:12:14 -04:00
Kevin Wojniak
9bfd04da2d Update liquid-c 2016-09-10 09:23:15 -07:00
Konstantin Tennhard
302185a7fc Standard filter truncatewords: force truncate_string to string
Currently, `truncatewords` raises a TypeError when the argument
`truncate_string` is an interger. This PR forces string coercion for any
value provided for this argument. Thus,

```ruby
assert_equal 'one two1', @filters.truncatewords("one two three", 2, 1)
```

holds true. Another option would be to raise a `Liquid::ArgumentError`.

What is preferred?
2016-09-09 16:50:50 -04:00
Michael Angell
6ed6e7e12f Allow :id to start with the word contains 2016-08-20 20:32:46 +10:00
Mike Angell
f41ed78378 Merge pull request #1 from Shopify/master
Pull inline with upstream
2016-08-17 21:30:08 +10:00
Florian Weingarten
50c85afc35 Merge pull request #786 from Shopify/bump-liquid-c
Bump LiquidC for whitespace changes
2016-08-11 13:38:42 -04:00
Florian Weingarten
5876dff326 Bump LiquidC for whitespace changes 2016-08-11 13:21:39 -04:00
Florian Weingarten
f25185631d Merge pull request #773 from evulse/whitespace-trim
Add whitespace control character and associated tests
2016-08-11 13:20:12 -04:00
Michael Angell
283f1bad18 Use .last instead of pop push method for updating last node in nodelist 2016-07-08 20:49:30 +10:00
Michael Angell
e1d40c7d89 Add whitespace control character and associated tests 2016-06-28 09:15:45 +10:00
Justin Li
19c6eb426a Merge pull request #769 from zacstewart/patch-1
Fix doc formatting of code examples in file_system
2016-06-15 17:10:11 -04:00
Zac Stewart
f87b06095d Fix doc formatting of code examples in file_system
These code examples are being rendered as paragraph text in the docs.
2016-06-15 15:34:14 -04:00
Gaurav Chande
b81d54e789 Merge pull request #761 from Shopify/range-to_liquid
Support Range Type
2016-06-02 16:48:30 -04:00
Gaurav Chande
00f53b16e8 Prevent Range usage in templates from blowing up 2016-06-02 16:38:44 -04:00
Gaurav Chande
e4cf55b112 Merge pull request #748 from Shopify/expose-tags
Make Template.tags loop-able
2016-04-25 11:59:37 -04:00
Gaurav Chande
5bb211d933 Ensure no tag leakage since registry is global 2016-04-25 11:50:46 -04:00
Gaurav Chande
6adc431a19 Make tag registry enumerable 2016-04-25 11:38:42 -04:00
Justin Li
23d2beed41 Merge pull request #744 from Shopify/raw-syntax-method
Make markup validation a method on Liquid::Raw
2016-04-13 17:08:02 -04:00
Drew Martin
a80ecb7678 make markup validation a method on Liquid::Raw 2016-04-13 14:52:30 -04:00
Florian Weingarten
361c695264 Merge pull request #736 from Shopify/abs-filter
Abs filter
2016-04-05 09:13:56 -04:00
Florian Weingarten
f93243cc1a abs filter 2016-04-04 09:32:31 -04:00
Florian Weingarten
1e533a52e7 Merge pull request #735 from Shopify/fix-to-number-for-negative-float-strings
Fix to_number filter for negative float strings
2016-03-31 15:52:51 -04:00
Dylan Thacker-Smith
3ea84f095f Merge pull request #734 from Shopify/concat-liquid-error
Raise a Liquid::Error when a non-array is passed into the concat filter.
2016-03-31 15:47:43 -04:00
Dylan Thacker-Smith
4239c899a4 Raise a Liquid::Error when a non-array is passed into the concat filter. 2016-03-31 15:47:06 -04:00
Florian Weingarten
1597f8859f Fix to_number filter for negative float strings 2016-03-31 09:18:55 -04:00
Florian Weingarten
b3dda384c9 Merge pull request #733 from Shopify/fix-some-ruby-warnings
Fix a bunch of Ruby warnings
2016-03-30 17:09:00 -04:00
Florian Weingarten
6828670bfe Merge pull request #732 from Shopify/v400rc2
Release v4.0.0rc2
2016-03-30 17:02:34 -04:00
Florian Weingarten
d2f16d92d6 Fix a bunch of Ruby warnings 2016-03-30 20:42:30 +00:00
Justin Li
d233acb483 Update history to reflect merge of #731 2016-03-30 16:36:57 -04:00
Florian Weingarten
8920e2a2a2 Release v4.0.0rc2 2016-03-30 20:13:21 +00:00
Justin Li
bfee507005 Merge pull request #731 from ismasan/duck_typed_maths_filters
Duck typed maths filters
2016-03-30 16:09:16 -04:00
Ismael Celis
929c89789f Test that all maths filters work with duck-typed #to_number 2016-03-30 13:35:09 -03:00
Ismael Celis
d03c4ae8e8 Allow Utils.to_number to work with anything that responds to #to_number 2016-03-30 01:57:21 -03:00
Justin Li
021bafd260 Merge pull request #725 from jeroenvisser101/performance-start-with-vs-regex
Use start_with? instead of Regex
2016-03-21 10:34:28 -04:00
Jeroen Visser
04c393ab07 Use start_with? instead of Regex
Performance is increased by doing this:

  require 'benchmark'
  require 'tempfile'

  n = 50000
  test = File.expand_path(Tempfile.new('foo'))
  Benchmark.bm(20) do |x|
    x.report("Regex:") do
      n.times { test =~ /\A#{test}/ }
    end
    x.report("String#start_with?:") do
      n.times { test =~ test.start_with?(test) }
    end
  end

Benchmark result:
                             user     system      total        real
  Regex:                 0.440000   0.010000   0.450000 (  0.447357)
  String#start_with?:    0.000000   0.000000   0.000000 (  0.006313)
2016-03-21 14:23:35 +01:00
Gaurav Chande
9a7778e52c Merge pull request #707 from Shopify/drop-without-context
@context not always present on a Drop
2016-03-01 18:07:16 -05:00
Gaurav Chande
dde00253f9 context is not always present on a Drop 2016-03-01 21:22:11 +00:00
Gaurav Chande
18d1644980 Merge pull request #705 from Shopify/register-filter-warn
Strainer#add_filter Raises on Private Override
2016-02-24 15:31:41 -05:00
Gaurav Chande
c424d47274 Add changelog entry for Strainer method override change 2016-02-24 20:23:57 +00:00
Gaurav Chande
8e6b9d503d Make Strainer also raise when registered method is overriden as protected 2016-02-24 20:23:49 +00:00
Gaurav Chande
8be38d1795 Strainer#add_filter should raise when registered method is overriden as private 2016-02-24 20:03:17 +00:00
Justin Li
3146d5c3f2 Grammatic and other fixes to CONTRIBUTING.md 2016-02-02 23:45:37 -05:00
Justin Li
0cc8b68a97 Make logic in Context#lookup_and_evaluate more understandable 2016-02-02 23:22:46 -05:00
Justin Li
5a50c12953 Update history to reflect merge of #691
[ci skip]
2016-02-02 23:14:41 -05:00
Justin Li
a6fa4c5c38 Merge pull request #691 from urbandictionary/missing_variables_and_filters
Merge pull request 691
2016-02-02 23:13:44 -05:00
Ivan Kuznetsov
dadd9b4dd2 Add strict_variables/strict_filters render options to check for undefined variables and filters 2016-02-03 10:49:33 +07:00
Justin Li
6434b8d2bb Merge pull request #696 from Shopify/no-send
Remove possibility for arbitrary sends
2016-02-02 11:01:46 -05:00
Justin Li
2d891ddd8f Merge pull request #695 from Shopify/assign-score
Take nested values into account for assign score
2016-02-01 13:14:40 -05:00
Justin Li
60b508b151 Use #each to avoid extra allocations 2016-02-01 13:01:25 -05:00
Justin Li
3891f14a1a Take nested values into account for assign score 2016-02-01 13:01:25 -05:00
Justin Li
198f0aa366 Add test for nested assign score bookkeeping 2016-02-01 13:01:23 -05:00
Justin Li
f2e6adf566 Remove arbitrary send vector 2016-02-01 11:38:40 -05:00
50 changed files with 1382 additions and 295 deletions

View File

@@ -1,9 +1,9 @@
language: ruby language: ruby
rvm: rvm:
- 2.0
- 2.1 - 2.1
- 2.2 - 2.2
- 2.3.3
- ruby-head - ruby-head
- jruby-head - jruby-head
# - rbx-2 # - rbx-2
@@ -19,6 +19,10 @@ matrix:
allow_failures: allow_failures:
- rvm: jruby-head - rvm: jruby-head
install:
- gem install rainbow -v 2.2.1
- bundle install
script: "bundle exec rake" script: "bundle exec rake"
notifications: notifications:

View File

@@ -4,23 +4,22 @@
* Bugfixes * Bugfixes
* Performance improvements * Performance improvements
* Features which are likely to be useful to the majority of Liquid users * Features that are likely to be useful to the majority of Liquid users
## Things we won't merge ## Things we won't merge
* Code which introduces considerable performance degrations * Code that introduces considerable performance degrations
* Code which touches performance critical parts of Liquid and comes without benchmarks * Code that touches performance-critical parts of Liquid and comes without benchmarks
* Features which are not important for most people (we want to keep the core Liquid code small and tidy) * Features that are not important for most people (we want to keep the core Liquid code small and tidy)
* Features which can easily be implemented on top of Liquid (for example as a custom filter or custom filesystem) * Features that can easily be implemented on top of Liquid (for example as a custom filter or custom filesystem)
* Code which comes without tests * Code that does not include tests
* Code which breaks existing tests * Code that breaks existing tests
## Workflow ## Workflow
* Fork the Liquid repository * Fork the Liquid repository
* Create a new branch in your fork * Create a new branch in your fork
* If it makes sense, add tests for your code and run a performance benchmark * If it makes sense, add tests for your code and/or run a performance benchmark
* Make sure all tests pass * Make sure all tests pass (`bundle exec rake`)
* Create a pull request * Create a pull request
* In the description, ping one of [@boourns](https://github.com/boourns), [@fw42](https://github.com/fw42), [@camilo](https://github.com/camilo), [@dylanahsmith](https://github.com/dylanahsmith), or [@arthurnn](https://github.com/arthurnn) and ask for a code review.

11
Gemfile
View File

@@ -1,14 +1,17 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gemspec gemspec
gem 'stackprof', platforms: :mri_21
gem 'stackprof', platforms: :mri
group :benchmark, :test do
gem 'benchmark-ips'
end
group :test do group :test do
gem 'spy', '0.4.1'
gem 'benchmark-ips'
gem 'rubocop', '0.34.2' gem 'rubocop', '0.34.2'
platform :mri do platform :mri do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: '2570693d8d03faa0df9160ec74348a7149436df3' gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'bd53db95de3d44d631e7c5a267c3d934e66107dd'
end end
end end

View File

@@ -1,8 +1,12 @@
# Liquid Change Log # Liquid Change Log
## 4.0.0 / not yet released / branch "master" ## 4.0.0 / 2016-12-14 / branch "4-0-stable"
### Changed ### Changed
* Render an opaque internal error by default for non-Liquid::Error (#835) [Dylan Thacker-Smith]
* Ruby 2.0 support dropped (#832) [Dylan Thacker-Smith]
* Add to_number Drop method to allow custom drops to work with number filters (#731)
* Add strict_variables and strict_filters options to detect undefined references (#691)
* Improve loop performance (#681) [Florian Weingarten] * Improve loop performance (#681) [Florian Weingarten]
* Rename Drop method `before_method` to `liquid_method_missing` (#661) [Thierry Joyal] * Rename Drop method `before_method` to `liquid_method_missing` (#661) [Thierry Joyal]
* Add url_decode filter to invert url_encode (#645) [Larry Archer] * Add url_decode filter to invert url_encode (#645) [Larry Archer]
@@ -16,9 +20,13 @@
* Add concat filter to concatenate arrays (#429) [Diogo Beato] * Add concat filter to concatenate arrays (#429) [Diogo Beato]
* Ruby 1.9 support dropped (#491) [Justin Li] * Ruby 1.9 support dropped (#491) [Justin Li]
* Liquid::Template.file_system's read_template_file method is no longer passed the context. (#441) [James Reid-Smith] * Liquid::Template.file_system's read_template_file method is no longer passed the context. (#441) [James Reid-Smith]
* Remove support for `liquid_methods` * Remove `liquid_methods` (See https://github.com/Shopify/liquid/pull/568 for replacement)
* Liquid::Template.register_filter raises when the module overrides registered public methods as private or protected (#705) [Gaurav Chande]
### Fixed ### Fixed
* Fix variable names being detected as an operator when starting with contains (#788) [Michael Angell]
* Fix include tag used with strict_variables (#828) [QuickPay]
* Fix map filter when value is a Proc (#672) [Guillaume Malette] * Fix map filter when value is a Proc (#672) [Guillaume Malette]
* Fix truncate filter when value is not a string (#672) [Guillaume Malette] * Fix truncate filter when value is not a string (#672) [Guillaume Malette]
* Fix behaviour of escape filter when input is nil (#665) [Tanel Jakobsoo] * Fix behaviour of escape filter when input is nil (#665) [Tanel Jakobsoo]

View File

@@ -73,3 +73,34 @@ This is useful for doing things like enabling strict mode only in the theme edit
It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created. It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created.
It is also recommended that you use it in the template editors of existing apps to give editors better error messages. It is also recommended that you use it in the template editors of existing apps to give editors better error messages.
### Undefined variables and filters
By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method.
You can improve this situation by passing `strict_variables: true` and/or `strict_filters: true` options to the `render` method.
When one of these options is set to true, all errors about undefined variables and undefined filters will be stored in `errors` array of a `Liquid::Template` instance.
Here are some examples:
```ruby
template = Liquid::Template.parse("{{x}} {{y}} {{z.a}} {{z.b}}")
template.render({ 'x' => 1, 'z' => { 'a' => 2 } }, { strict_variables: true })
#=> '1 2 ' # when a variable is undefined, it's rendered as nil
template.errors
#=> [#<Liquid::UndefinedVariable: Liquid error: undefined variable y>, #<Liquid::UndefinedVariable: Liquid error: undefined variable b>]
```
```ruby
template = Liquid::Template.parse("{{x | filter1 | upcase}}")
template.render({ 'x' => 'foo' }, { strict_filters: true })
#=> '' # when at least one filter in the filter chain is undefined, a whole expression is rendered as nil
template.errors
#=> [#<Liquid::UndefinedFilter: Liquid error: undefined filter filter1>]
```
If you want to raise on a first exception instead of pushing all of them in `errors`, you can use `render!` method:
```ruby
template = Liquid::Template.parse("{{x}} {{y}}")
template.render!({ 'x' => 1}, { strict_variables: true })
#=> Liquid::UndefinedVariable: Liquid error: undefined variable y
```

View File

@@ -1,3 +1,3 @@
machine: machine:
ruby: ruby:
version: ruby-2.0 version: ruby-2.1

View File

@@ -24,6 +24,7 @@ module Liquid
ArgumentSeparator = ','.freeze ArgumentSeparator = ','.freeze
FilterArgumentSeparator = ':'.freeze FilterArgumentSeparator = ':'.freeze
VariableAttributeSeparator = '.'.freeze VariableAttributeSeparator = '.'.freeze
WhitespaceControl = '-'.freeze
TagStart = /\{\%/ TagStart = /\{\%/
TagEnd = /\%\}/ TagEnd = /\%\}/
VariableSignature = /\(?[\w\-\.\[\]]\)?/ VariableSignature = /\(?[\w\-\.\[\]]\)?/
@@ -34,7 +35,7 @@ module Liquid
QuotedString = /"[^"]*"|'[^']*'/ QuotedString = /"[^"]*"|'[^']*'/
QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o
TagAttributes = /(\w+)\s*\:\s*(#{QuotedFragment})/o TagAttributes = /(\w+)\s*\:\s*(#{QuotedFragment})/o
AnyStartingTag = /\{\{|\{\%/ AnyStartingTag = /#{TagStart}|#{VariableStart}/o
PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om
TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om
VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o

View File

@@ -1,5 +1,7 @@
module Liquid module Liquid
class Block < Tag class Block < Tag
MAX_DEPTH = 100
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
@blank = true @blank = true
@@ -48,17 +50,25 @@ module Liquid
protected protected
def parse_body(body, tokens) def parse_body(body, tokens)
body.parse(tokens, parse_context) do |end_tag_name, end_tag_params| if parse_context.depth >= MAX_DEPTH
@blank &&= body.blank? raise StackLevelError, "Nesting too deep".freeze
end
parse_context.depth += 1
begin
body.parse(tokens, parse_context) do |end_tag_name, end_tag_params|
@blank &&= body.blank?
return false if end_tag_name == block_delimiter return false if end_tag_name == block_delimiter
unless end_tag_name unless end_tag_name
raise SyntaxError.new(parse_context.locale.t("errors.syntax.tag_never_closed".freeze, block_name: block_name)) raise SyntaxError.new(parse_context.locale.t("errors.syntax.tag_never_closed".freeze, block_name: block_name))
end
# this tag is not registered with the system
# pass it to the current block for special handling or error reporting
unknown_tag(end_tag_name, end_tag_params, tokens)
end end
ensure
# this tag is not registered with the system parse_context.depth -= 1
# pass it to the current block for special handling or error reporting
unknown_tag(end_tag_name, end_tag_params, tokens)
end end
true true

View File

@@ -1,7 +1,7 @@
module Liquid module Liquid
class BlockBody class BlockBody
FullToken = /\A#{TagStart}\s*(\w+)\s*(.*)?#{TagEnd}\z/om FullToken = /\A#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*?)#{WhitespaceControl}?#{TagEnd}\z/om
ContentOfVariable = /\A#{VariableStart}(.*)#{VariableEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om
TAGSTART = "{%".freeze TAGSTART = "{%".freeze
VARSTART = "{{".freeze VARSTART = "{{".freeze
@@ -18,6 +18,7 @@ module Liquid
unless token.empty? unless token.empty?
case case
when token.start_with?(TAGSTART) when token.start_with?(TAGSTART)
whitespace_handler(token, parse_context)
if token =~ FullToken if token =~ FullToken
tag_name = $1 tag_name = $1
markup = $2 markup = $2
@@ -35,9 +36,14 @@ module Liquid
raise_missing_tag_terminator(token, parse_context) raise_missing_tag_terminator(token, parse_context)
end end
when token.start_with?(VARSTART) when token.start_with?(VARSTART)
whitespace_handler(token, parse_context)
@nodelist << create_variable(token, parse_context) @nodelist << create_variable(token, parse_context)
@blank = false @blank = false
else else
if parse_context.trim_whitespace
token.lstrip!
end
parse_context.trim_whitespace = false
@nodelist << token @nodelist << token
@blank &&= !!(token =~ /\A\s*\z/) @blank &&= !!(token =~ /\A\s*\z/)
end end
@@ -48,6 +54,16 @@ module Liquid
yield nil, nil yield nil, nil
end end
def whitespace_handler(token, parse_context)
if token[2] == WhitespaceControl
previous_token = @nodelist.last
if previous_token.is_a? String
previous_token.rstrip!
end
end
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
end
def blank? def blank?
@blank @blank
end end
@@ -76,8 +92,12 @@ module Liquid
end end
rescue MemoryError => e rescue MemoryError => e
raise e raise e
rescue UndefinedVariable, UndefinedDropMethod, UndefinedFilter => e
context.handle_error(e, token.line_number)
output << nil
rescue ::StandardError => e rescue ::StandardError => e
output << context.handle_error(e, token.line_number) line_number = token.is_a?(String) ? nil : token.line_number
output << context.handle_error(e, line_number)
end end
end end
@@ -87,7 +107,7 @@ module Liquid
private private
def render_node(node, context) def render_node(node, context)
node_output = (node.respond_to?(:render) ? node.render(context) : node) node_output = node.is_a?(String) ? node : node.render(context)
node_output = node_output.is_a?(Array) ? node_output.join : node_output.to_s node_output = node_output.is_a?(Array) ? node_output.join : node_output.to_s
context.resource_limits.render_length += node_output.length context.resource_limits.render_length += node_output.length

View File

@@ -41,16 +41,22 @@ module Liquid
end end
def evaluate(context = Context.new) def evaluate(context = Context.new)
result = interpret_condition(left, right, operator, context) condition = self
result = nil
loop do
result = interpret_condition(condition.left, condition.right, condition.operator, context)
case @child_relation case condition.child_relation
when :or when :or
result || @child_condition.evaluate(context) break if result
when :and when :and
result && @child_condition.evaluate(context) break unless result
else else
result break
end
condition = condition.child_condition
end end
result
end end
def or(condition) def or(condition)
@@ -75,6 +81,10 @@ module Liquid
"#<Condition #{[@left, @operator, @right].compact.join(' '.freeze)}>" "#<Condition #{[@left, @operator, @right].compact.join(' '.freeze)}>"
end end
protected
attr_reader :child_relation, :child_condition
private private
def equal_variables(left, right) def equal_variables(left, right)
@@ -110,7 +120,7 @@ module Liquid
if operation.respond_to?(:call) if operation.respond_to?(:call)
operation.call(self, left, right) operation.call(self, left, right)
elsif left.respond_to?(operation) && right.respond_to?(operation) elsif left.respond_to?(operation) && right.respond_to?(operation) && !left.is_a?(Hash) && !right.is_a?(Hash)
begin begin
left.send(operation, right) left.send(operation, right)
rescue ::ArgumentError => e rescue ::ArgumentError => e

View File

@@ -13,7 +13,7 @@ module Liquid
# context['bob'] #=> nil class Context # context['bob'] #=> nil class Context
class Context class Context
attr_reader :scopes, :errors, :registers, :environments, :resource_limits attr_reader :scopes, :errors, :registers, :environments, :resource_limits
attr_accessor :exception_handler, :template_name, :partial, :global_filter attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil) def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil)
@environments = [environments].flatten @environments = [environments].flatten
@@ -21,13 +21,15 @@ module Liquid
@registers = registers @registers = registers
@errors = [] @errors = []
@partial = false @partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits) @resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
squash_instance_assigns_with_environments squash_instance_assigns_with_environments
@this_stack_used = false @this_stack_used = false
self.exception_renderer = Template.default_exception_renderer
if rethrow_errors if rethrow_errors
self.exception_handler = ->(e) { raise } self.exception_renderer = ->(e) { raise }
end end
@interrupts = [] @interrupts = []
@@ -73,30 +75,11 @@ module Liquid
end end
def handle_error(e, line_number = nil) def handle_error(e, line_number = nil)
if e.is_a?(Liquid::Error) e = internal_error unless e.is_a?(Liquid::Error)
e.template_name ||= template_name e.template_name ||= template_name
e.line_number ||= line_number e.line_number ||= line_number
end
output = nil
if exception_handler
result = exception_handler.call(e)
case result
when Exception
e = result
if e.is_a?(Liquid::Error)
e.template_name ||= template_name
e.line_number ||= line_number
end
when String
output = result
else
raise if result
end
end
errors.push(e) errors.push(e)
output || Liquid::Error.render(e) exception_renderer.call(e).to_s
end end
def invoke(method, *args) def invoke(method, *args)
@@ -106,7 +89,7 @@ module Liquid
# Push new local scope on the stack. use <tt>Context#stack</tt> instead # Push new local scope on the stack. use <tt>Context#stack</tt> instead
def push(new_scope = {}) def push(new_scope = {})
@scopes.unshift(new_scope) @scopes.unshift(new_scope)
raise StackLevelError, "Nesting too deep".freeze if @scopes.length > 100 raise StackLevelError, "Nesting too deep".freeze if @scopes.length > Block::MAX_DEPTH
end end
# Merge a hash of variables in the current local scope # Merge a hash of variables in the current local scope
@@ -177,7 +160,7 @@ module Liquid
end end
# Fetches an object starting at the local scope and then moving up the hierachy # Fetches an object starting at the local scope and then moving up the hierachy
def find_variable(key) def find_variable(key, raise_on_not_found: true)
# This was changed from find() to find_index() because this is a very hot # This was changed from find() to find_index() because this is a very hot
# path and find_index() is optimized in MRI to reduce object allocation # path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) } index = @scopes.find_index { |s| s.key?(key) }
@@ -187,7 +170,7 @@ module Liquid
if scope.nil? if scope.nil?
@environments.each do |e| @environments.each do |e|
variable = lookup_and_evaluate(e, key) variable = lookup_and_evaluate(e, key, raise_on_not_found: raise_on_not_found)
unless variable.nil? unless variable.nil?
scope = e scope = e
break break
@@ -196,7 +179,7 @@ module Liquid
end end
scope ||= @environments.last || @scopes.last scope ||= @environments.last || @scopes.last
variable ||= lookup_and_evaluate(scope, key) variable ||= lookup_and_evaluate(scope, key, raise_on_not_found: raise_on_not_found)
variable = variable.to_liquid variable = variable.to_liquid
variable.context = self if variable.respond_to?(:context=) variable.context = self if variable.respond_to?(:context=)
@@ -204,8 +187,14 @@ module Liquid
variable variable
end end
def lookup_and_evaluate(obj, key) def lookup_and_evaluate(obj, key, raise_on_not_found: true)
if (value = obj[key]).is_a?(Proc) && obj.respond_to?(:[]=) if @strict_variables && raise_on_not_found && obj.respond_to?(:key?) && !obj.key?(key)
raise Liquid::UndefinedVariable, "undefined variable #{key}"
end
value = obj[key]
if value.is_a?(Proc) && obj.respond_to?(:[]=)
obj[key] = (value.arity == 0) ? value.call : value.call(self) obj[key] = (value.arity == 0) ? value.call : value.call(self)
else else
value value
@@ -214,6 +203,13 @@ module Liquid
private private
def internal_error
# raise and catch to set backtrace and cause on exception
raise Liquid::InternalError, 'internal'
rescue Liquid::InternalError => exc
exc
end
def squash_instance_assigns_with_environments def squash_instance_assigns_with_environments
@scopes.last.each_key do |k| @scopes.last.each_key do |k|
@environments.each do |env| @environments.each do |env|

View File

@@ -24,8 +24,9 @@ module Liquid
attr_writer :context attr_writer :context
# Catch all for the method # Catch all for the method
def liquid_method_missing(_method) def liquid_method_missing(method)
nil return nil unless @context && @context.strict_variables
raise Liquid::UndefinedDropMethod, "undefined method #{method}"
end end
# called by liquid to invoke a drop # called by liquid to invoke a drop
@@ -61,16 +62,17 @@ module Liquid
end end
def self.invokable_methods def self.invokable_methods
unless @invokable_methods @invokable_methods ||= begin
blacklist = Liquid::Drop.public_instance_methods + [:each] blacklist = Liquid::Drop.public_instance_methods + [:each]
if include?(Enumerable) if include?(Enumerable)
blacklist += Enumerable.public_instance_methods blacklist += Enumerable.public_instance_methods
blacklist -= [:sort, :count, :first, :min, :max, :include?] blacklist -= [:sort, :count, :first, :min, :max, :include?]
end end
whitelist = [:to_liquid] + (public_instance_methods - blacklist) whitelist = [:to_liquid] + (public_instance_methods - blacklist)
@invokable_methods = Set.new(whitelist.map(&:to_s)) Set.new(whitelist.map(&:to_s))
end end
@invokable_methods
end end
end end
end end

View File

@@ -17,14 +17,6 @@ module Liquid
str str
end end
def self.render(e)
if e.is_a?(Liquid::Error)
e.to_s
else
"Liquid error: #{e}"
end
end
private private
def message_prefix def message_prefix
@@ -56,4 +48,9 @@ module Liquid
MemoryError = Class.new(Error) MemoryError = Class.new(Error)
ZeroDivisionError = Class.new(Error) ZeroDivisionError = Class.new(Error)
FloatDomainError = Class.new(Error) FloatDomainError = Class.new(Error)
UndefinedVariable = Class.new(Error)
UndefinedDropMethod = Class.new(Error)
UndefinedFilter = Class.new(Error)
MethodOverrideError = Class.new(Error)
InternalError = Class.new(Error)
end end

View File

@@ -25,6 +25,12 @@ class Numeric # :nodoc:
end end
end end
class Range # :nodoc:
def to_liquid
self
end
end
class Time # :nodoc: class Time # :nodoc:
def to_liquid def to_liquid
self self

View File

@@ -8,8 +8,8 @@ module Liquid
# #
# Example: # Example:
# #
# Liquid::Template.file_system = Liquid::LocalFileSystem.new(template_path) # Liquid::Template.file_system = Liquid::LocalFileSystem.new(template_path)
# liquid = Liquid::Template.parse(template) # liquid = Liquid::Template.parse(template)
# #
# This will parse the template with a LocalFileSystem implementation rooted at 'template_path'. # This will parse the template with a LocalFileSystem implementation rooted at 'template_path'.
class BlankFileSystem class BlankFileSystem
@@ -26,10 +26,10 @@ module Liquid
# #
# Example: # Example:
# #
# file_system = Liquid::LocalFileSystem.new("/some/path") # file_system = Liquid::LocalFileSystem.new("/some/path")
# #
# file_system.full_path("mypartial") # => "/some/path/_mypartial.liquid" # file_system.full_path("mypartial") # => "/some/path/_mypartial.liquid"
# file_system.full_path("dir/mypartial") # => "/some/path/dir/_mypartial.liquid" # file_system.full_path("dir/mypartial") # => "/some/path/dir/_mypartial.liquid"
# #
# Optionally in the second argument you can specify a custom pattern for template filenames. # Optionally in the second argument you can specify a custom pattern for template filenames.
# The Kernel::sprintf format specification is used. # The Kernel::sprintf format specification is used.
@@ -37,9 +37,9 @@ module Liquid
# #
# Example: # Example:
# #
# file_system = Liquid::LocalFileSystem.new("/some/path", "%s.html") # file_system = Liquid::LocalFileSystem.new("/some/path", "%s.html")
# #
# file_system.full_path("index") # => "/some/path/index.html" # file_system.full_path("index") # => "/some/path/index.html"
# #
class LocalFileSystem class LocalFileSystem
attr_accessor :root attr_accessor :root
@@ -65,7 +65,7 @@ module Liquid
File.join(root, @pattern % template_path) File.join(root, @pattern % template_path)
end end
raise FileSystemError, "Illegal template path '#{File.expand_path(full_path)}'" unless File.expand_path(full_path) =~ /\A#{File.expand_path(root)}/ raise FileSystemError, "Illegal template path '#{File.expand_path(full_path)}'" unless File.expand_path(full_path).start_with?(File.expand_path(root))
full_path full_path
end end

View File

@@ -18,10 +18,10 @@ module Liquid
DOUBLE_STRING_LITERAL = /"[^\"]*"/ DOUBLE_STRING_LITERAL = /"[^\"]*"/
NUMBER_LITERAL = /-?\d+(\.\d+)?/ NUMBER_LITERAL = /-?\d+(\.\d+)?/
DOTDOT = /\.\./ DOTDOT = /\.\./
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains/ COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
def initialize(input) def initialize(input)
@ss = StringScanner.new(input.rstrip) @ss = StringScanner.new(input)
end end
def tokenize def tokenize
@@ -29,6 +29,7 @@ module Liquid
until @ss.eos? until @ss.eos?
@ss.skip(/\s*/) @ss.skip(/\s*/)
break if @ss.eos?
tok = case tok = case
when t = @ss.scan(COMPARISON_OPERATOR) then [:comparison, t] when t = @ss.scan(COMPARISON_OPERATOR) then [:comparison, t]
when t = @ss.scan(SINGLE_STRING_LITERAL) then [:string, t] when t = @ss.scan(SINGLE_STRING_LITERAL) then [:string, t]

View File

@@ -22,3 +22,5 @@
tag_never_closed: "'%{block_name}' tag was never closed" tag_never_closed: "'%{block_name}' tag was never closed"
meta_syntax_error: "Liquid syntax error: #{e.message}" meta_syntax_error: "Liquid syntax error: #{e.message}"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
argument:
include: "Argument error in tag 'include' - Illegal template name"

View File

@@ -1,12 +1,13 @@
module Liquid module Liquid
class ParseContext class ParseContext
attr_accessor :partial, :locale, :line_number attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :warnings, :error_mode attr_reader :partial, :warnings, :error_mode
def initialize(options = {}) def initialize(options = {})
@template_options = options ? options.dup : {} @template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new @locale = @template_options[:locale] ||= I18n.new
@warnings = [] @warnings = []
self.depth = 0
self.partial = false self.partial = false
end end

View File

@@ -33,7 +33,7 @@ module Liquid
end end
def escape(input) def escape(input)
CGI.escapeHTML(input).untaint unless input.nil? CGI.escapeHTML(input.to_s).untaint unless input.nil?
end end
alias_method :h, :escape alias_method :h, :escape
@@ -42,11 +42,11 @@ module Liquid
end end
def url_encode(input) def url_encode(input)
CGI.escape(input) unless input.nil? CGI.escape(input.to_s) unless input.nil?
end end
def url_decode(input) def url_decode(input)
CGI.unescape(input) unless input.nil? CGI.unescape(input.to_s) unless input.nil?
end end
def slice(input, offset, length = nil) def slice(input, offset, length = nil)
@@ -65,9 +65,10 @@ module Liquid
return if input.nil? return if input.nil?
input_str = input.to_s input_str = input.to_s
length = Utils.to_integer(length) length = Utils.to_integer(length)
l = length - truncate_string.length truncate_string_str = truncate_string.to_s
l = length - truncate_string_str.length
l = 0 if l < 0 l = 0 if l < 0
input_str.length > length ? input_str[0...l] + truncate_string : input_str input_str.length > length ? input_str[0...l] + truncate_string_str : input_str
end end
def truncatewords(input, words = 15, truncate_string = "...".freeze) def truncatewords(input, words = 15, truncate_string = "...".freeze)
@@ -76,7 +77,7 @@ module Liquid
words = Utils.to_integer(words) words = Utils.to_integer(words)
l = words - 1 l = words - 1
l = 0 if l < 0 l = 0 if l < 0
wordlist.length > l ? wordlist[0..l].join(" ".freeze) + truncate_string : input wordlist.length > l ? wordlist[0..l].join(" ".freeze) + truncate_string.to_s : input
end end
# Split input string into an array of substrings separated by given pattern. # Split input string into an array of substrings separated by given pattern.
@@ -85,7 +86,7 @@ module Liquid
# <div class="summary">{{ post | split '//' | first }}</div> # <div class="summary">{{ post | split '//' | first }}</div>
# #
def split(input, pattern) def split(input, pattern)
input.to_s.split(pattern) input.to_s.split(pattern.to_s)
end end
def strip(input) def strip(input)
@@ -124,9 +125,15 @@ module Liquid
elsif ary.empty? # The next two cases assume a non-empty array. elsif ary.empty? # The next two cases assume a non-empty array.
[] []
elsif ary.first.respond_to?(:[]) && !ary.first[property].nil? elsif ary.first.respond_to?(:[]) && !ary.first[property].nil?
ary.sort { |a, b| a[property] <=> b[property] } ary.sort do |a, b|
elsif ary.first.respond_to?(property) a = a[property]
ary.sort { |a, b| a.send(property) <=> b.send(property) } b = b[property]
if a && b
a <=> b
else
a ? -1 : 1
end
end
end end
end end
@@ -141,8 +148,6 @@ module Liquid
[] []
elsif ary.first.respond_to?(:[]) && !ary.first[property].nil? elsif ary.first.respond_to?(:[]) && !ary.first[property].nil?
ary.sort { |a, b| a[property].casecmp(b[property]) } ary.sort { |a, b| a[property].casecmp(b[property]) }
elsif ary.first.respond_to?(property)
ary.sort { |a, b| a.send(property).casecmp(b.send(property)) }
end end
end end
@@ -191,8 +196,6 @@ module Liquid
[] []
elsif ary.first.respond_to?(:[]) elsif ary.first.respond_to?(:[])
ary.reject{ |a| a[property].nil? } ary.reject{ |a| a[property].nil? }
elsif ary.first.respond_to?(property)
ary.reject { |a| a.send(property).nil? }
end end
end end
@@ -222,6 +225,9 @@ module Liquid
end end
def concat(input, array) def concat(input, array)
unless array.respond_to?(:to_ary)
raise ArgumentError.new("concat filter requires an array argument")
end
InputIterator.new(input).concat(array) InputIterator.new(input).concat(array)
end end
@@ -292,6 +298,12 @@ module Liquid
array.last if array.respond_to?(:last) array.last if array.respond_to?(:last)
end end
# absolute value
def abs(input)
result = Utils.to_number(input).abs
result.is_a?(BigDecimal) ? result.to_f : result
end
# addition # addition
def plus(input, operand) def plus(input, operand)
apply_operation(input, operand, :+) apply_operation(input, operand, :+)
@@ -372,11 +384,11 @@ module Liquid
end end
def join(glue) def join(glue)
to_a.join(glue) to_a.join(glue.to_s)
end end
def concat(args) def concat(args)
to_a.concat args to_a.concat(args)
end end
def reverse def reverse

View File

@@ -26,14 +26,20 @@ module Liquid
end end
def self.add_filter(filter) def self.add_filter(filter)
raise ArgumentError, "Expected module but got: #{f.class}" unless filter.is_a?(Module) raise ArgumentError, "Expected module but got: #{filter.class}" unless filter.is_a?(Module)
unless self.class.include?(filter) unless self.include?(filter)
send(:include, filter) invokable_non_public_methods = (filter.private_instance_methods + filter.protected_instance_methods).select { |m| invokable?(m) }
@filter_methods.merge(filter.public_instance_methods.map(&:to_s)) if invokable_non_public_methods.any?
raise MethodOverrideError, "Filter overrides registered public methods as non public: #{invokable_non_public_methods.join(', ')}"
else
send(:include, filter)
@filter_methods.merge(filter.public_instance_methods.map(&:to_s))
end
end end
end end
def self.global_filter(filter) def self.global_filter(filter)
@@strainer_class_cache.clear
@@global_strainer.add_filter(filter) @@global_strainer.add_filter(filter)
end end
@@ -48,6 +54,8 @@ module Liquid
def invoke(method, *args) def invoke(method, *args)
if self.class.invokable?(method) if self.class.invokable?(method)
send(method, *args) send(method, *args)
elsif @context && @context.strict_filters
raise Liquid::UndefinedFilter, "undefined filter #{method}"
else else
args.first args.first
end end

View File

@@ -23,16 +23,28 @@ module Liquid
def render(context) def render(context)
val = @from.render(context) val = @from.render(context)
context.scopes.last[@to] = val context.scopes.last[@to] = val
context.resource_limits.assign_score += assign_score_of(val)
inc = val.instance_of?(String) || val.instance_of?(Array) || val.instance_of?(Hash) ? val.length : 1
context.resource_limits.assign_score += inc
''.freeze ''.freeze
end end
def blank? def blank?
true true
end end
private
def assign_score_of(val)
if val.instance_of?(String)
val.length
elsif val.instance_of?(Array) || val.instance_of?(Hash)
sum = 1
# Uses #each to avoid extra allocations.
val.each { |child| sum += assign_score_of(child) }
sum
else
1
end
end
end end
Template.register_tag('assign'.freeze, Assign) Template.register_tag('assign'.freeze, Assign)

View File

@@ -23,7 +23,7 @@ module Liquid
# {{ item.name }} # {{ item.name }}
# {% end %} # {% end %}
# #
# To reverse the for loop simply use {% for item in collection reversed %} # To reverse the for loop simply use {% for item in collection reversed %} (note that the flag's spelling is different to the filter `reverse`)
# #
# == Available variables: # == Available variables:
# #
@@ -46,10 +46,15 @@ module Liquid
class For < Block class For < Block
Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o
attr_reader :collection_name
attr_reader :variable_name
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
@from = @limit = nil
parse_with_selected_parser(markup) parse_with_selected_parser(markup)
@for_block = BlockBody.new @for_block = BlockBody.new
@else_block = nil
end end
def parse(tokens) def parse(tokens)
@@ -124,7 +129,7 @@ module Liquid
end end
collection = context.evaluate(@collection_name) collection = context.evaluate(@collection_name)
collection = collection.to_a if collection.is_a?(Range) collection = collection.step(1).to_a if collection.is_a?(Range)
limit = context.evaluate(@limit) limit = context.evaluate(@limit)
to = limit ? limit.to_i + from : nil to = limit ? limit.to_i + from : nil

View File

@@ -83,17 +83,20 @@ module Liquid
def strict_parse(markup) def strict_parse(markup)
p = Parser.new(markup) p = Parser.new(markup)
condition = parse_binary_comparison(p) condition = parse_binary_comparisons(p)
p.consume(:end_of_string) p.consume(:end_of_string)
condition condition
end end
def parse_binary_comparison(p) def parse_binary_comparisons(p)
condition = parse_comparison(p) condition = parse_comparison(p)
if op = (p.id?('and'.freeze) || p.id?('or'.freeze)) first_condition = condition
condition.send(op, parse_binary_comparison(p)) while op = (p.id?('and'.freeze) || p.id?('or'.freeze))
child_condition = parse_comparison(p)
condition.send(op, child_condition)
condition = child_condition
end end
condition first_condition
end end
def parse_comparison(p) def parse_comparison(p)

View File

@@ -42,14 +42,15 @@ module Liquid
def render(context) def render(context)
template_name = context.evaluate(@template_name_expr) template_name = context.evaluate(@template_name_expr)
partial = load_cached_partial(template_name, context) raise ArgumentError.new(options[:locale].t("errors.argument.include")) unless template_name
partial = load_cached_partial(template_name, context)
context_variable_name = template_name.split('/'.freeze).last context_variable_name = template_name.split('/'.freeze).last
variable = if @variable_name_expr variable = if @variable_name_expr
context.evaluate(@variable_name_expr) context.evaluate(@variable_name_expr)
else else
context.find_variable(template_name) context.find_variable(template_name, raise_on_not_found: false)
end end
old_template_name = context.template_name old_template_name = context.template_name

View File

@@ -6,9 +6,7 @@ module Liquid
def initialize(tag_name, markup, parse_context) def initialize(tag_name, markup, parse_context)
super super
unless markup =~ Syntax ensure_valid_markup(tag_name, markup, parse_context)
raise SyntaxError.new(parse_context.locale.t("errors.syntax.tag_unexpected_args".freeze, tag: tag_name))
end
end end
def parse(tokens) def parse(tokens)
@@ -35,6 +33,14 @@ module Liquid
def blank? def blank?
@body.empty? @body.empty?
end end
protected
def ensure_valid_markup(tag_name, markup, parse_context)
unless markup =~ Syntax
raise SyntaxError.new(parse_context.locale.t("errors.syntax.tag_unexpected_args".freeze, tag: tag_name))
end
end
end end
Template.register_tag('raw'.freeze, Raw) Template.register_tag('raw'.freeze, Raw)

View File

@@ -19,8 +19,10 @@ module Liquid
@@file_system = BlankFileSystem.new @@file_system = BlankFileSystem.new
class TagRegistry class TagRegistry
include Enumerable
def initialize def initialize
@tags = {} @tags = {}
@cache = {} @cache = {}
end end
@@ -41,6 +43,10 @@ module Liquid
@cache.delete(tag_name) @cache.delete(tag_name)
end end
def each(&block)
@tags.each(&block)
end
private private
def lookup_class(name) def lookup_class(name)
@@ -63,6 +69,11 @@ module Liquid
# :error raises an error when tainted output is used # :error raises an error when tainted output is used
attr_writer :taint_mode attr_writer :taint_mode
attr_accessor :default_exception_renderer
Template.default_exception_renderer = lambda do |exception|
exception
end
def file_system def file_system
@@file_system @@file_system
end end
@@ -80,11 +91,11 @@ module Liquid
end end
def error_mode def error_mode
@error_mode || :lax @error_mode ||= :lax
end end
def taint_mode def taint_mode
@taint_mode || :lax @taint_mode ||= :lax
end end
# Pass a module with filter methods which should be available # Pass a module with filter methods which should be available
@@ -107,6 +118,7 @@ module Liquid
end end
def initialize def initialize
@rethrow_errors = false
@resource_limits = ResourceLimits.new(self.class.default_resource_limits) @resource_limits = ResourceLimits.new(self.class.default_resource_limits)
end end
@@ -160,7 +172,7 @@ module Liquid
c = args.shift c = args.shift
if @rethrow_errors if @rethrow_errors
c.exception_handler = ->(e) { raise } c.exception_renderer = ->(e) { raise }
end end
c c
@@ -181,12 +193,7 @@ module Liquid
registers.merge!(options[:registers]) if options[:registers].is_a?(Hash) registers.merge!(options[:registers]) if options[:registers].is_a?(Hash)
context.add_filters(options[:filters]) if options[:filters] apply_options_to_context(context, options)
context.global_filter = options[:global_filter] if options[:global_filter]
context.exception_handler = options[:exception_handler] if options[:exception_handler]
when Module, Array when Module, Array
context.add_filters(args.pop) context.add_filters(args.pop)
end end
@@ -235,5 +242,13 @@ module Liquid
yield yield
end end
end end
def apply_options_to_context(context, options)
context.add_filters(options[:filters]) if options[:filters]
context.global_filter = options[:global_filter] if options[:global_filter]
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
context.strict_variables = options[:strict_variables] if options[:strict_variables]
context.strict_filters = options[:strict_filters] if options[:strict_filters]
end
end end
end end

View File

@@ -4,7 +4,7 @@ module Liquid
def initialize(source, line_numbers = false) def initialize(source, line_numbers = false)
@source = source @source = source
@line_number = 1 if line_numbers @line_number = line_numbers ? 1 : nil
@tokens = tokenize @tokens = tokenize
end end

View File

@@ -50,9 +50,13 @@ module Liquid
when Numeric when Numeric
obj obj
when String when String
(obj.strip =~ /\A\d+\.\d+\z/) ? BigDecimal.new(obj) : obj.to_i (obj.strip =~ /\A-?\d+\.\d+\z/) ? BigDecimal.new(obj) : obj.to_i
else else
0 if obj.respond_to?(:to_number)
obj.to_number
else
0
end
end end
end end

View File

@@ -55,9 +55,11 @@ module Liquid
object = object.send(key).to_liquid object = object.send(key).to_liquid
# No key was present with the desired value and it wasn't one of the directly supported # No key was present with the desired value and it wasn't one of the directly supported
# keywords either. The only thing we got left is to return nil # keywords either. The only thing we got left is to return nil or
# raise an exception if `strict_variables` option is set to true
else else
return nil return nil unless context.strict_variables
raise Liquid::UndefinedVariable, "undefined variable #{key}"
end end
# If we are dealing with a drop here we have to # If we are dealing with a drop here we have to

View File

@@ -1,4 +1,4 @@
# encoding: utf-8 # encoding: utf-8
module Liquid module Liquid
VERSION = "4.0.0.rc1" VERSION = "4.0.0"
end end

View File

@@ -15,6 +15,7 @@ Gem::Specification.new do |s|
s.license = "MIT" s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup." # s.description = "A secure, non-evaling end user template engine with aesthetic markup."
s.required_ruby_version = ">= 2.1.0"
s.required_rubygems_version = ">= 1.3.7" s.required_rubygems_version = ">= 1.3.7"
s.test_files = Dir.glob("{test}/**/*") s.test_files = Dir.glob("{test}/**/*")
@@ -24,6 +25,6 @@ Gem::Specification.new do |s|
s.require_path = "lib" s.require_path = "lib"
s.add_development_dependency 'rake' s.add_development_dependency 'rake', '~> 11.3'
s.add_development_dependency 'minitest' s.add_development_dependency 'minitest'
end end

View File

@@ -5,7 +5,7 @@ Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new profiler = ThemeRunner.new
Benchmark.ips do |x| Benchmark.ips do |x|
x.time = 60 x.time = 10
x.warmup = 5 x.warmup = 5
puts puts
@@ -13,5 +13,6 @@ Benchmark.ips do |x|
puts puts
x.report("parse:") { profiler.compile } x.report("parse:") { profiler.compile }
x.report("parse & run:") { profiler.run } x.report("render:") { profiler.render }
x.report("parse & render:") { profiler.run }
end end

View File

@@ -21,53 +21,100 @@ class ThemeRunner
end end
end end
# Load all templates into memory, do this now so that # Initialize a new liquid ThemeRunner instance
# we don't profile IO. # Will load all templates into memory, do this now so that we don't profile IO.
def initialize def initialize
@tests = Dir[__dir__ + '/tests/**/*.liquid'].collect do |test| @tests = Dir[__dir__ + '/tests/**/*.liquid'].collect do |test|
next if File.basename(test) == 'theme.liquid' next if File.basename(test) == 'theme.liquid'
theme_path = File.dirname(test) + '/theme.liquid' theme_path = File.dirname(test) + '/theme.liquid'
{
[File.read(test), (File.file?(theme_path) ? File.read(theme_path) : nil), test] liquid: File.read(test),
layout: (File.file?(theme_path) ? File.read(theme_path) : nil),
template_name: test
}
end.compact end.compact
compile_all_tests
end end
# `compile` will test just the compilation portion of liquid without any templates
def compile def compile
# Dup assigns because will make some changes to them @tests.each do |test_hash|
Liquid::Template.new.parse(test_hash[:liquid])
@tests.each do |liquid, layout, template_name| Liquid::Template.new.parse(test_hash[:layout])
tmpl = Liquid::Template.new
tmpl.parse(liquid)
tmpl = Liquid::Template.new
tmpl.parse(layout)
end end
end end
# `run` is called to benchmark rendering and compiling at the same time
def run def run
# Dup assigns because will make some changes to them each_test do |liquid, layout, assigns, page_template, template_name|
assigns = Database.tables.dup
@tests.each do |liquid, layout, template_name|
# Compute page_tempalte outside of profiler run, uninteresting to profiler
page_template = File.basename(template_name, File.extname(template_name))
compile_and_render(liquid, layout, assigns, page_template, template_name) compile_and_render(liquid, layout, assigns, page_template, template_name)
end end
end end
# `render` is called to benchmark just the render portion of liquid
def render
@compiled_tests.each do |test|
tmpl = test[:tmpl]
assigns = test[:assigns]
layout = test[:layout]
if layout
assigns['content_for_layout'] = tmpl.render!(assigns)
layout.render!(assigns)
else
tmpl.render!(assigns)
end
end
end
private
def compile_and_render(template, layout, assigns, page_template, template_file) def compile_and_render(template, layout, assigns, page_template, template_file)
compiled_test = compile_test(template, layout, assigns, page_template, template_file)
assigns['content_for_layout'] = compiled_test[:tmpl].render!(assigns)
compiled_test[:layout].render!(assigns) if layout
end
def compile_all_tests
@compiled_tests = []
each_test do |liquid, layout, assigns, page_template, template_name|
@compiled_tests << compile_test(liquid, layout, assigns, page_template, template_name)
end
@compiled_tests
end
def compile_test(template, layout, assigns, page_template, template_file)
tmpl = init_template(page_template, template_file)
parsed_template = tmpl.parse(template).dup
if layout
parsed_layout = tmpl.parse(layout)
{ tmpl: parsed_template, assigns: assigns, layout: parsed_layout }
else
{ tmpl: parsed_template, assigns: assigns }
end
end
# utility method with similar functionality needed in `compile_all_tests` and `run`
def each_test
# Dup assigns because will make some changes to them
assigns = Database.tables.dup
@tests.each do |test_hash|
# Compute page_template outside of profiler run, uninteresting to profiler
page_template = File.basename(test_hash[:template_name], File.extname(test_hash[:template_name]))
yield(test_hash[:liquid], test_hash[:layout], assigns, page_template, test_hash[:template_name])
end
end
# set up a new Liquid::Template object for use in `compile_and_render` and `compile_test`
def init_template(page_template, template_file)
tmpl = Liquid::Template.new tmpl = Liquid::Template.new
tmpl.assigns['page_title'] = 'Page title' tmpl.assigns['page_title'] = 'Page title'
tmpl.assigns['template'] = page_template tmpl.assigns['template'] = page_template
tmpl.registers[:file_system] = ThemeRunner::FileSystem.new(File.dirname(template_file)) tmpl.registers[:file_system] = ThemeRunner::FileSystem.new(File.dirname(template_file))
tmpl
content_for_layout = tmpl.parse(template).render!(assigns)
if layout
assigns['content_for_layout'] = content_for_layout
tmpl.parse(layout).render!(assigns)
else
content_for_layout
end
end end
end end

View File

@@ -94,7 +94,23 @@ class ErrorHandlingTest < Minitest::Test
) )
end end
assert_match /Liquid syntax error \(line 4\)/, err.message assert_match(/Liquid syntax error \(line 4\)/, err.message)
end
def test_with_line_numbers_adds_numbers_to_parser_errors_with_whitespace_trim
err = assert_raises(SyntaxError) do
Liquid::Template.parse(%q(
foobar
{%- "cat" | foobar -%}
bla
),
line_numbers: true
)
end
assert_match(/Liquid syntax error \(line 4\)/, err.message)
end end
def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
@@ -186,22 +202,40 @@ class ErrorHandlingTest < Minitest::Test
end end
end end
def test_exception_handler_with_string_result def test_default_exception_renderer_with_internal_error
template = Liquid::Template.parse('This is an argument error: {{ errors.argument_error }}')
output = template.render({ 'errors' => ErrorDrop.new }, exception_handler: ->(e) { '' })
assert_equal 'This is an argument error: ', output
assert_equal [ArgumentError], template.errors.map(&:class)
end
class InternalError < Liquid::Error
end
def test_exception_handler_with_exception_result
template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true) template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true)
handler = ->(e) { e.is_a?(Liquid::Error) ? e : InternalError.new('internal') }
output = template.render({ 'errors' => ErrorDrop.new }, exception_handler: handler) output = template.render({ 'errors' => ErrorDrop.new })
assert_equal 'This is a runtime error: Liquid error (line 1): internal', output assert_equal 'This is a runtime error: Liquid error (line 1): internal', output
assert_equal [InternalError], template.errors.map(&:class) assert_equal [Liquid::InternalError], template.errors.map(&:class)
end
def test_setting_default_exception_renderer
old_exception_renderer = Liquid::Template.default_exception_renderer
exceptions = []
Liquid::Template.default_exception_renderer = ->(e) { exceptions << e; '' }
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}')
output = template.render({ 'errors' => ErrorDrop.new })
assert_equal 'This is a runtime error: ', output
assert_equal [Liquid::ArgumentError], template.errors.map(&:class)
ensure
Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer
end
def test_exception_renderer_exposing_non_liquid_error
template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true)
exceptions = []
handler = ->(e) { exceptions << e; e.cause }
output = template.render({ 'errors' => ErrorDrop.new }, exception_renderer: handler)
assert_equal 'This is a runtime error: runtime error', output
assert_equal [Liquid::InternalError], exceptions.map(&:class)
assert_equal exceptions, template.errors
assert_equal '#<RuntimeError: runtime error>', exceptions.first.cause.inspect
end end
class TestFileSystem class TestFileSystem

View File

@@ -1,18 +1,18 @@
require 'test_helper' require 'test_helper'
module MoneyFilter
def money(input)
sprintf(' %d$ ', input)
end
end
module CanadianMoneyFilter
def money(input)
sprintf(' %d$ CAD ', input)
end
end
class HashOrderingTest < Minitest::Test class HashOrderingTest < Minitest::Test
module MoneyFilter
def money(input)
sprintf(' %d$ ', input)
end
end
module CanadianMoneyFilter
def money(input)
sprintf(' %d$ CAD ', input)
end
end
include Liquid include Liquid
def test_global_register_order def test_global_register_order

View File

@@ -115,4 +115,8 @@ class ParsingQuirksTest < Minitest::Test
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}") assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end end
end end
def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', 'containsallshipments' => true)
end
end # ParsingQuirksTest end # ParsingQuirksTest

View File

@@ -9,6 +9,10 @@ end
class SecurityTest < Minitest::Test class SecurityTest < Minitest::Test
include Liquid include Liquid
def setup
@assigns = {}
end
def test_no_instance_eval def test_no_instance_eval
text = %( {{ '1+1' | instance_eval }} ) text = %( {{ '1+1' | instance_eval }} )
expected = %( 1+1 ) expected = %( 1+1 )
@@ -59,4 +63,18 @@ class SecurityTest < Minitest::Test
assert_equal [], (Symbol.all_symbols - current_symbols) assert_equal [], (Symbol.all_symbols - current_symbols)
end end
def test_max_depth_nested_blocks_does_not_raise_exception
depth = Liquid::Block::MAX_DEPTH
code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
assert_equal "rendered", Template.parse(code).render!
end
def test_more_than_max_depth_nested_blocks_raises_exception
depth = Liquid::Block::MAX_DEPTH + 1
code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
assert_raises(Liquid::StackLevelError) do
Template.parse(code).render!
end
end
end # SecurityTest end # SecurityTest

View File

@@ -41,6 +41,16 @@ class TestEnumerable < Liquid::Drop
end end
end end
class NumberLikeThing < Liquid::Drop
def initialize(amount)
@amount = amount
end
def to_number
@amount
end
end
class StandardFiltersTest < Minitest::Test class StandardFiltersTest < Minitest::Test
include Liquid include Liquid
@@ -105,21 +115,29 @@ class StandardFiltersTest < Minitest::Test
assert_equal '...', @filters.truncate('1234567890', 0) assert_equal '...', @filters.truncate('1234567890', 0)
assert_equal '1234567890', @filters.truncate('1234567890') assert_equal '1234567890', @filters.truncate('1234567890')
assert_equal "测试...", @filters.truncate("测试测试测试测试", 5) assert_equal "测试...", @filters.truncate("测试测试测试测试", 5)
assert_equal '12341', @filters.truncate("1234567890", 5, 1)
end end
def test_split def test_split
assert_equal ['12', '34'], @filters.split('12~34', '~') assert_equal ['12', '34'], @filters.split('12~34', '~')
assert_equal ['A? ', ' ,Z'], @filters.split('A? ~ ~ ~ ,Z', '~ ~ ~') assert_equal ['A? ', ' ,Z'], @filters.split('A? ~ ~ ~ ,Z', '~ ~ ~')
assert_equal ['A?Z'], @filters.split('A?Z', '~') assert_equal ['A?Z'], @filters.split('A?Z', '~')
# Regexp works although Liquid does not support.
assert_equal ['A', 'Z'], @filters.split('AxZ', /x/)
assert_equal [], @filters.split(nil, ' ') assert_equal [], @filters.split(nil, ' ')
assert_equal ['A', 'Z'], @filters.split('A1Z', 1)
end end
def test_escape def test_escape
assert_equal '&lt;strong&gt;', @filters.escape('<strong>') assert_equal '&lt;strong&gt;', @filters.escape('<strong>')
assert_equal nil, @filters.escape(nil) assert_equal '1', @filters.escape(1)
assert_equal '2001-02-03', @filters.escape(Date.new(2001, 2, 3))
assert_nil @filters.escape(nil)
end
def test_h
assert_equal '&lt;strong&gt;', @filters.h('<strong>') assert_equal '&lt;strong&gt;', @filters.h('<strong>')
assert_equal '1', @filters.h(1)
assert_equal '2001-02-03', @filters.h(Date.new(2001, 2, 3))
assert_nil @filters.h(nil)
end end
def test_escape_once def test_escape_once
@@ -128,14 +146,18 @@ class StandardFiltersTest < Minitest::Test
def test_url_encode def test_url_encode
assert_equal 'foo%2B1%40example.com', @filters.url_encode('foo+1@example.com') assert_equal 'foo%2B1%40example.com', @filters.url_encode('foo+1@example.com')
assert_equal nil, @filters.url_encode(nil) assert_equal '1', @filters.url_encode(1)
assert_equal '2001-02-03', @filters.url_encode(Date.new(2001, 2, 3))
assert_nil @filters.url_encode(nil)
end end
def test_url_decode def test_url_decode
assert_equal 'foo bar', @filters.url_decode('foo+bar') assert_equal 'foo bar', @filters.url_decode('foo+bar')
assert_equal 'foo bar', @filters.url_decode('foo%20bar') assert_equal 'foo bar', @filters.url_decode('foo%20bar')
assert_equal 'foo+1@example.com', @filters.url_decode('foo%2B1%40example.com') assert_equal 'foo+1@example.com', @filters.url_decode('foo%2B1%40example.com')
assert_equal nil, @filters.url_decode(nil) assert_equal '1', @filters.url_decode(1)
assert_equal '2001-02-03', @filters.url_decode(Date.new(2001, 2, 3))
assert_nil @filters.url_decode(nil)
end end
def test_truncatewords def test_truncatewords
@@ -144,6 +166,7 @@ class StandardFiltersTest < Minitest::Test
assert_equal 'one two three', @filters.truncatewords('one two three') assert_equal 'one two three', @filters.truncatewords('one two three')
assert_equal 'Two small (13&#8221; x 5.5&#8221; x 10&#8221; high) baskets fit inside one large basket (13&#8221;...', @filters.truncatewords('Two small (13&#8221; x 5.5&#8221; x 10&#8221; high) baskets fit inside one large basket (13&#8221; x 16&#8221; x 10.5&#8221; high) with cover.', 15) assert_equal 'Two small (13&#8221; x 5.5&#8221; x 10&#8221; high) baskets fit inside one large basket (13&#8221;...', @filters.truncatewords('Two small (13&#8221; x 5.5&#8221; x 10&#8221; high) baskets fit inside one large basket (13&#8221; x 16&#8221; x 10.5&#8221; high) with cover.', 15)
assert_equal "测试测试测试测试", @filters.truncatewords('测试测试测试测试', 5) assert_equal "测试测试测试测试", @filters.truncatewords('测试测试测试测试', 5)
assert_equal 'one two1', @filters.truncatewords("one two three", 2, 1)
end end
def test_strip_html def test_strip_html
@@ -159,6 +182,7 @@ class StandardFiltersTest < Minitest::Test
def test_join def test_join
assert_equal '1 2 3 4', @filters.join([1, 2, 3, 4]) assert_equal '1 2 3 4', @filters.join([1, 2, 3, 4])
assert_equal '1 - 2 - 3 - 4', @filters.join([1, 2, 3, 4], ' - ') assert_equal '1 - 2 - 3 - 4', @filters.join([1, 2, 3, 4], ' - ')
assert_equal '1121314', @filters.join([1, 2, 3, 4], 1)
end end
def test_sort def test_sort
@@ -166,6 +190,24 @@ class StandardFiltersTest < Minitest::Test
assert_equal [{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a") assert_equal [{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a")
end end
def test_sort_when_property_is_sometimes_missing_puts_nils_last
input = [
{ "price" => 4, "handle" => "alpha" },
{ "handle" => "beta" },
{ "price" => 1, "handle" => "gamma" },
{ "handle" => "delta" },
{ "price" => 2, "handle" => "epsilon" }
]
expectation = [
{ "price" => 1, "handle" => "gamma" },
{ "price" => 2, "handle" => "epsilon" },
{ "price" => 4, "handle" => "alpha" },
{ "handle" => "delta" },
{ "handle" => "beta" }
]
assert_equal expectation, @filters.sort(input, "price")
end
def test_sort_empty_array def test_sort_empty_array
assert_equal [], @filters.sort([], "a") assert_equal [], @filters.sort([], "a")
end end
@@ -300,7 +342,7 @@ class StandardFiltersTest < Minitest::Test
assert_equal "#{Date.today.year}", @filters.date('today', '%Y') assert_equal "#{Date.today.year}", @filters.date('today', '%Y')
assert_equal "#{Date.today.year}", @filters.date('Today', '%Y') assert_equal "#{Date.today.year}", @filters.date('Today', '%Y')
assert_equal nil, @filters.date(nil, "%B") assert_nil @filters.date(nil, "%B")
assert_equal '', @filters.date('', "%B") assert_equal '', @filters.date('', "%B")
@@ -313,8 +355,8 @@ class StandardFiltersTest < Minitest::Test
def test_first_last def test_first_last
assert_equal 1, @filters.first([1, 2, 3]) assert_equal 1, @filters.first([1, 2, 3])
assert_equal 3, @filters.last([1, 2, 3]) assert_equal 3, @filters.last([1, 2, 3])
assert_equal nil, @filters.first([]) assert_nil @filters.first([])
assert_equal nil, @filters.last([]) assert_nil @filters.last([])
end end
def test_replace def test_replace
@@ -364,20 +406,38 @@ class StandardFiltersTest < Minitest::Test
def test_plus def test_plus
assert_template_result "2", "{{ 1 | plus:1 }}" assert_template_result "2", "{{ 1 | plus:1 }}"
assert_template_result "2.0", "{{ '1' | plus:'1.0' }}" assert_template_result "2.0", "{{ '1' | plus:'1.0' }}"
assert_template_result "5", "{{ price | plus:'2' }}", 'price' => NumberLikeThing.new(3)
end end
def test_minus def test_minus
assert_template_result "4", "{{ input | minus:operand }}", 'input' => 5, 'operand' => 1 assert_template_result "4", "{{ input | minus:operand }}", 'input' => 5, 'operand' => 1
assert_template_result "2.3", "{{ '4.3' | minus:'2' }}" assert_template_result "2.3", "{{ '4.3' | minus:'2' }}"
assert_template_result "5", "{{ price | minus:'2' }}", 'price' => NumberLikeThing.new(7)
end
def test_abs
assert_template_result "17", "{{ 17 | abs }}"
assert_template_result "17", "{{ -17 | abs }}"
assert_template_result "17", "{{ '17' | abs }}"
assert_template_result "17", "{{ '-17' | abs }}"
assert_template_result "0", "{{ 0 | abs }}"
assert_template_result "0", "{{ '0' | abs }}"
assert_template_result "17.42", "{{ 17.42 | abs }}"
assert_template_result "17.42", "{{ -17.42 | abs }}"
assert_template_result "17.42", "{{ '17.42' | abs }}"
assert_template_result "17.42", "{{ '-17.42' | abs }}"
end end
def test_times def test_times
assert_template_result "12", "{{ 3 | times:4 }}" assert_template_result "12", "{{ 3 | times:4 }}"
assert_template_result "0", "{{ 'foo' | times:4 }}" assert_template_result "0", "{{ 'foo' | times:4 }}"
assert_template_result "6", "{{ '2.1' | times:3 | replace: '.','-' | plus:0}}" assert_template_result "6", "{{ '2.1' | times:3 | replace: '.','-' | plus:0}}"
assert_template_result "7.25", "{{ 0.0725 | times:100 }}" assert_template_result "7.25", "{{ 0.0725 | times:100 }}"
assert_template_result "-7.25", '{{ "-0.0725" | times:100 }}'
assert_template_result "7.25", '{{ "-0.0725" | times: -100 }}'
assert_template_result "4", "{{ price | times:2 }}", 'price' => NumberLikeThing.new(2)
end end
def test_divided_by def test_divided_by
@@ -391,6 +451,8 @@ class StandardFiltersTest < Minitest::Test
assert_raises(Liquid::ZeroDivisionError) do assert_raises(Liquid::ZeroDivisionError) do
assert_template_result "4", "{{ 1 | modulo: 0 }}" assert_template_result "4", "{{ 1 | modulo: 0 }}"
end end
assert_template_result "5", "{{ price | divided_by:2 }}", 'price' => NumberLikeThing.new(10)
end end
def test_modulo def test_modulo
@@ -398,6 +460,8 @@ class StandardFiltersTest < Minitest::Test
assert_raises(Liquid::ZeroDivisionError) do assert_raises(Liquid::ZeroDivisionError) do
assert_template_result "4", "{{ 1 | modulo: 0 }}" assert_template_result "4", "{{ 1 | modulo: 0 }}"
end end
assert_template_result "1", "{{ price | modulo:2 }}", 'price' => NumberLikeThing.new(3)
end end
def test_round def test_round
@@ -407,6 +471,9 @@ class StandardFiltersTest < Minitest::Test
assert_raises(Liquid::FloatDomainError) do assert_raises(Liquid::FloatDomainError) do
assert_template_result "4", "{{ 1.0 | divided_by: 0.0 | round }}" assert_template_result "4", "{{ 1.0 | divided_by: 0.0 | round }}"
end end
assert_template_result "5", "{{ price | round }}", 'price' => NumberLikeThing.new(4.6)
assert_template_result "4", "{{ price | round }}", 'price' => NumberLikeThing.new(4.3)
end end
def test_ceil def test_ceil
@@ -415,6 +482,8 @@ class StandardFiltersTest < Minitest::Test
assert_raises(Liquid::FloatDomainError) do assert_raises(Liquid::FloatDomainError) do
assert_template_result "4", "{{ 1.0 | divided_by: 0.0 | ceil }}" assert_template_result "4", "{{ 1.0 | divided_by: 0.0 | ceil }}"
end end
assert_template_result "5", "{{ price | ceil }}", 'price' => NumberLikeThing.new(4.6)
end end
def test_floor def test_floor
@@ -423,6 +492,8 @@ class StandardFiltersTest < Minitest::Test
assert_raises(Liquid::FloatDomainError) do assert_raises(Liquid::FloatDomainError) do
assert_template_result "4", "{{ 1.0 | divided_by: 0.0 | floor }}" assert_template_result "4", "{{ 1.0 | divided_by: 0.0 | floor }}"
end end
assert_template_result "5", "{{ price | floor }}", 'price' => NumberLikeThing.new(5.4)
end end
def test_append def test_append
@@ -436,8 +507,7 @@ class StandardFiltersTest < Minitest::Test
assert_equal [1, 2, 'a'], @filters.concat([1, 2], ['a']) assert_equal [1, 2, 'a'], @filters.concat([1, 2], ['a'])
assert_equal [1, 2, 10], @filters.concat([1, 2], [10]) assert_equal [1, 2, 10], @filters.concat([1, 2], [10])
assert_raises(TypeError) do assert_raises(Liquid::ArgumentError, "concat filter requires an array argument") do
# no implicit conversion of Fixnum into Array
@filters.concat([1, 2], 10) @filters.concat([1, 2], 10)
end end
end end

View File

@@ -48,6 +48,10 @@ HERE
def test_for_with_variable_range def test_for_with_variable_range
assert_template_result(' 1 2 3 ', '{%for item in (1..foobar) %} {{item}} {%endfor%}', "foobar" => 3) assert_template_result(' 1 2 3 ', '{%for item in (1..foobar) %} {{item}} {%endfor%}', "foobar" => 3)
assert_template_result(' 1.0 2.0 3.0 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1..3.0))
assert_template_result(' 1.0 2.0 3.0 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1.0..3))
assert_template_result(' 1.0 2.0 3.0 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1.0..3.0))
assert_template_result(' 1.5 2.5 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1.5..3))
end end
def test_for_with_hash_value_range def test_for_with_hash_value_range

View File

@@ -137,7 +137,7 @@ class IncludeTagTest < Minitest::Test
Liquid::Template.file_system = infinite_file_system.new Liquid::Template.file_system = infinite_file_system.new
assert_raises(Liquid::StackLevelError, SystemStackError) do assert_raises(Liquid::StackLevelError) do
Template.parse("{% include 'loop' %}").render! Template.parse("{% include 'loop' %}").render!
end end
end end
@@ -217,6 +217,17 @@ class IncludeTagTest < Minitest::Test
end end
end end
def test_render_raise_argument_error_when_template_is_undefined
assert_raises(Liquid::ArgumentError) do
template = Liquid::Template.parse('{% include undefined_variable %}')
template.render!
end
assert_raises(Liquid::ArgumentError) do
template = Liquid::Template.parse('{% include nil %}')
template.render!
end
end
def test_including_via_variable_value def test_including_via_variable_value
assert_template_result "from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}" assert_template_result "from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}"
@@ -224,4 +235,11 @@ class IncludeTagTest < Minitest::Test
assert_template_result "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", "foo" => { 'title' => 'Draft 151cm' } assert_template_result "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", "foo" => { 'title' => 'Draft 151cm' }
end end
def test_including_with_strict_variables
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn)
template.render(nil, strict_variables: true)
assert_equal [], template.errors
end
end # IncludeTagTest end # IncludeTagTest

View File

@@ -24,8 +24,8 @@ class RawTagTest < Minitest::Test
end end
def test_invalid_raw def test_invalid_raw
assert_match_syntax_error /tag was never closed/, '{% raw %} foo' assert_match_syntax_error(/tag was never closed/, '{% raw %} foo')
assert_match_syntax_error /Valid syntax/, '{% raw } foo {% endraw %}' assert_match_syntax_error(/Valid syntax/, '{% raw } foo {% endraw %}')
assert_match_syntax_error /Valid syntax/, '{% raw } foo %}{% endraw %}' assert_match_syntax_error(/Valid syntax/, '{% raw } foo %}{% endraw %}')
end end
end end

View File

@@ -27,6 +27,12 @@ class ErroneousDrop < Liquid::Drop
end end
end end
class DropWithUndefinedMethod < Liquid::Drop
def foo
'foo'
end
end
class TemplateTest < Minitest::Test class TemplateTest < Minitest::Test
include Liquid include Liquid
@@ -133,6 +139,17 @@ class TemplateTest < Minitest::Test
refute_nil t.resource_limits.assign_score refute_nil t.resource_limits.assign_score
end end
def test_resource_limits_assign_score_nested
t = Template.parse("{% assign foo = 'aaaa' | reverse %}")
t.resource_limits.assign_score_limit = 3
assert_equal "Liquid error: Memory limits exceeded", t.render
assert t.resource_limits.reached?
t.resource_limits.assign_score_limit = 5
assert_equal "", t.render!
end
def test_resource_limits_aborts_rendering_after_first_error def test_resource_limits_aborts_rendering_after_first_error
t = Template.parse("{% for a in (1..100) %} foo1 {% endfor %} bar {% for a in (1..100) %} foo2 {% endfor %}") t = Template.parse("{% for a in (1..100) %} foo1 {% endfor %} bar {% for a in (1..100) %} foo2 {% endfor %}")
t.resource_limits.render_score_limit = 50 t.resource_limits.render_score_limit = 50
@@ -198,16 +215,20 @@ class TemplateTest < Minitest::Test
assert_equal 'ruby error in drop', e.message assert_equal 'ruby error in drop', e.message
end end
def test_exception_handler_doesnt_reraise_if_it_returns_false def test_exception_renderer_that_returns_string
exception = nil exception = nil
Template.parse("{{ 1 | divided_by: 0 }}").render({}, exception_handler: ->(e) { exception = e; false }) handler = ->(e) { exception = e; '<!-- error -->' }
output = Template.parse("{{ 1 | divided_by: 0 }}").render({}, exception_renderer: handler)
assert exception.is_a?(Liquid::ZeroDivisionError) assert exception.is_a?(Liquid::ZeroDivisionError)
assert_equal '<!-- error -->', output
end end
def test_exception_handler_does_reraise_if_it_returns_true def test_exception_renderer_that_raises
exception = nil exception = nil
assert_raises(Liquid::ZeroDivisionError) do assert_raises(Liquid::ZeroDivisionError) do
Template.parse("{{ 1 | divided_by: 0 }}").render({}, exception_handler: ->(e) { exception = e; true }) Template.parse("{{ 1 | divided_by: 0 }}").render({}, exception_renderer: ->(e) { exception = e; raise })
end end
assert exception.is_a?(Liquid::ZeroDivisionError) assert exception.is_a?(Liquid::ZeroDivisionError)
end end
@@ -225,4 +246,78 @@ class TemplateTest < Minitest::Test
assert_equal 'BOB filtered', rendered_template assert_equal 'BOB filtered', rendered_template
end end
def test_undefined_variables
t = Template.parse("{{x}} {{y}} {{z.a}} {{z.b}} {{z.c.d}}")
result = t.render({ 'x' => 33, 'z' => { 'a' => 32, 'c' => { 'e' => 31 } } }, { strict_variables: true })
assert_equal '33 32 ', result
assert_equal 3, t.errors.count
assert_instance_of Liquid::UndefinedVariable, t.errors[0]
assert_equal 'Liquid error: undefined variable y', t.errors[0].message
assert_instance_of Liquid::UndefinedVariable, t.errors[1]
assert_equal 'Liquid error: undefined variable b', t.errors[1].message
assert_instance_of Liquid::UndefinedVariable, t.errors[2]
assert_equal 'Liquid error: undefined variable d', t.errors[2].message
end
def test_undefined_variables_raise
t = Template.parse("{{x}} {{y}} {{z.a}} {{z.b}} {{z.c.d}}")
assert_raises UndefinedVariable do
t.render!({ 'x' => 33, 'z' => { 'a' => 32, 'c' => { 'e' => 31 } } }, { strict_variables: true })
end
end
def test_undefined_drop_methods
d = DropWithUndefinedMethod.new
t = Template.new.parse('{{ foo }} {{ woot }}')
result = t.render(d, { strict_variables: true })
assert_equal 'foo ', result
assert_equal 1, t.errors.count
assert_instance_of Liquid::UndefinedDropMethod, t.errors[0]
end
def test_undefined_drop_methods_raise
d = DropWithUndefinedMethod.new
t = Template.new.parse('{{ foo }} {{ woot }}')
assert_raises UndefinedDropMethod do
t.render!(d, { strict_variables: true })
end
end
def test_undefined_filters
t = Template.parse("{{a}} {{x | upcase | somefilter1 | somefilter2 | somefilter3}}")
filters = Module.new do
def somefilter3(v)
"-#{v}-"
end
end
result = t.render({ 'a' => 123, 'x' => 'foo' }, { filters: [filters], strict_filters: true })
assert_equal '123 ', result
assert_equal 1, t.errors.count
assert_instance_of Liquid::UndefinedFilter, t.errors[0]
assert_equal 'Liquid error: undefined filter somefilter1', t.errors[0].message
end
def test_undefined_filters_raise
t = Template.parse("{{x | somefilter1 | upcase | somefilter2}}")
assert_raises UndefinedFilter do
t.render!({ 'x' => 'foo' }, { strict_filters: true })
end
end
def test_using_range_literal_works_as_expected
t = Template.parse("{% assign foo = (x..y) %}{{ foo }}")
result = t.render({ 'x' => 1, 'y' => 5 })
assert_equal '1..5', result
t = Template.parse("{% assign nums = (x..y) %}{% for num in nums %}{{ num }}{% endfor %}")
result = t.render({ 'x' => 1, 'y' => 5 })
assert_equal '12345', result
end
end end

View File

@@ -0,0 +1,525 @@
require 'test_helper'
class TrimModeTest < Minitest::Test
include Liquid
# Make sure the trim isn't applied to standard output
def test_standard_output
text = <<-END_TEMPLATE
<div>
<p>
{{ 'John' }}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
John
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_variable_output_with_multiple_blank_lines
text = <<-END_TEMPLATE
<div>
<p>
{{- 'John' -}}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>John</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_tag_output_with_multiple_blank_lines
text = <<-END_TEMPLATE
<div>
<p>
{%- if true -%}
yes
{%- endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>yes</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
# Make sure the trim isn't applied to standard tags
def test_standard_tags
whitespace = ' '
text = <<-END_TEMPLATE
<div>
<p>
{% if true %}
yes
{% endif %}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
#{whitespace}
yes
#{whitespace}
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
text = <<-END_TEMPLATE
<div>
<p>
{% if false %}
no
{% endif %}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
#{whitespace}
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
# Make sure the trim isn't too agressive
def test_no_trim_output
text = '<p>{{- \'John\' -}}</p>'
expected = '<p>John</p>'
assert_template_result(expected, text)
end
# Make sure the trim isn't too agressive
def test_no_trim_tags
text = '<p>{%- if true -%}yes{%- endif -%}</p>'
expected = '<p>yes</p>'
assert_template_result(expected, text)
text = '<p>{%- if false -%}no{%- endif -%}</p>'
expected = '<p></p>'
assert_template_result(expected, text)
end
def test_single_line_outer_tag
text = '<p> {%- if true %} yes {% endif -%} </p>'
expected = '<p> yes </p>'
assert_template_result(expected, text)
text = '<p> {%- if false %} no {% endif -%} </p>'
expected = '<p></p>'
assert_template_result(expected, text)
end
def test_single_line_inner_tag
text = '<p> {% if true -%} yes {%- endif %} </p>'
expected = '<p> yes </p>'
assert_template_result(expected, text)
text = '<p> {% if false -%} no {%- endif %} </p>'
expected = '<p> </p>'
assert_template_result(expected, text)
end
def test_single_line_post_tag
text = '<p> {% if true -%} yes {% endif -%} </p>'
expected = '<p> yes </p>'
assert_template_result(expected, text)
text = '<p> {% if false -%} no {% endif -%} </p>'
expected = '<p> </p>'
assert_template_result(expected, text)
end
def test_single_line_pre_tag
text = '<p> {%- if true %} yes {%- endif %} </p>'
expected = '<p> yes </p>'
assert_template_result(expected, text)
text = '<p> {%- if false %} no {%- endif %} </p>'
expected = '<p> </p>'
assert_template_result(expected, text)
end
def test_pre_trim_output
text = <<-END_TEMPLATE
<div>
<p>
{{- 'John' }}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>John
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_pre_trim_tags
text = <<-END_TEMPLATE
<div>
<p>
{%- if true %}
yes
{%- endif %}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
yes
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
text = <<-END_TEMPLATE
<div>
<p>
{%- if false %}
no
{%- endif %}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_post_trim_output
text = <<-END_TEMPLATE
<div>
<p>
{{ 'John' -}}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
John</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_post_trim_tags
text = <<-END_TEMPLATE
<div>
<p>
{% if true -%}
yes
{% endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
yes
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
text = <<-END_TEMPLATE
<div>
<p>
{% if false -%}
no
{% endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_pre_and_post_trim_tags
text = <<-END_TEMPLATE
<div>
<p>
{%- if true %}
yes
{% endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
yes
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
text = <<-END_TEMPLATE
<div>
<p>
{%- if false %}
no
{% endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p></p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_post_and_pre_trim_tags
text = <<-END_TEMPLATE
<div>
<p>
{% if true -%}
yes
{%- endif %}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
yes
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
whitespace = ' '
text = <<-END_TEMPLATE
<div>
<p>
{% if false -%}
no
{%- endif %}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>
#{whitespace}
</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_trim_output
text = <<-END_TEMPLATE
<div>
<p>
{{- 'John' -}}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>John</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_trim_tags
text = <<-END_TEMPLATE
<div>
<p>
{%- if true -%}
yes
{%- endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>yes</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
text = <<-END_TEMPLATE
<div>
<p>
{%- if false -%}
no
{%- endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p></p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_whitespace_trim_output
text = <<-END_TEMPLATE
<div>
<p>
{{- 'John' -}},
{{- '30' -}}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>John,30</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_whitespace_trim_tags
text = <<-END_TEMPLATE
<div>
<p>
{%- if true -%}
yes
{%- endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>yes</p>
</div>
END_EXPECTED
assert_template_result(expected, text)
text = <<-END_TEMPLATE
<div>
<p>
{%- if false -%}
no
{%- endif -%}
</p>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p></p>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_complex_trim_output
text = <<-END_TEMPLATE
<div>
<p>
{{- 'John' -}}
{{- '30' -}}
</p>
<b>
{{ 'John' -}}
{{- '30' }}
</b>
<i>
{{- 'John' }}
{{ '30' -}}
</i>
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
<p>John30</p>
<b>
John30
</b>
<i>John
30</i>
</div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_complex_trim
text = <<-END_TEMPLATE
<div>
{%- if true -%}
{%- if true -%}
<p>
{{- 'John' -}}
</p>
{%- endif -%}
{%- endif -%}
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div><p>John</p></div>
END_EXPECTED
assert_template_result(expected, text)
end
def test_raw_output
whitespace = ' '
text = <<-END_TEMPLATE
<div>
{% raw %}
{%- if true -%}
<p>
{{- 'John' -}}
</p>
{%- endif -%}
{% endraw %}
</div>
END_TEMPLATE
expected = <<-END_EXPECTED
<div>
#{whitespace}
{%- if true -%}
<p>
{{- 'John' -}}
</p>
{%- endif -%}
#{whitespace}
</div>
END_EXPECTED
assert_template_result(expected, text)
end
end # TrimModeTest

View File

@@ -2,7 +2,6 @@
ENV["MT_NO_EXPECTATIONS"] = "1" ENV["MT_NO_EXPECTATIONS"] = "1"
require 'minitest/autorun' require 'minitest/autorun'
require 'spy/integration'
$LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib')) $LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib'))
require 'liquid.rb' require 'liquid.rb'

View File

@@ -46,6 +46,8 @@ class BlockUnitTest < Minitest::Test
def test_with_custom_tag def test_with_custom_tag
Liquid::Template.register_tag("testtag", Block) Liquid::Template.register_tag("testtag", Block)
assert Liquid::Template.parse("{% testtag %} {% endtesttag %}") assert Liquid::Template.parse("{% testtag %} {% endtesttag %}")
ensure
Liquid::Template.tags.delete('testtag')
end end
private private

View File

@@ -3,50 +3,54 @@ require 'test_helper'
class ConditionUnitTest < Minitest::Test class ConditionUnitTest < Minitest::Test
include Liquid include Liquid
def setup
@context = Liquid::Context.new
end
def test_basic_condition def test_basic_condition
assert_equal false, Condition.new(1, '==', 2).evaluate assert_equal false, Condition.new(1, '==', 2).evaluate
assert_equal true, Condition.new(1, '==', 1).evaluate assert_equal true, Condition.new(1, '==', 1).evaluate
end end
def test_default_operators_evalute_true def test_default_operators_evalute_true
assert_evalutes_true 1, '==', 1 assert_evaluates_true 1, '==', 1
assert_evalutes_true 1, '!=', 2 assert_evaluates_true 1, '!=', 2
assert_evalutes_true 1, '<>', 2 assert_evaluates_true 1, '<>', 2
assert_evalutes_true 1, '<', 2 assert_evaluates_true 1, '<', 2
assert_evalutes_true 2, '>', 1 assert_evaluates_true 2, '>', 1
assert_evalutes_true 1, '>=', 1 assert_evaluates_true 1, '>=', 1
assert_evalutes_true 2, '>=', 1 assert_evaluates_true 2, '>=', 1
assert_evalutes_true 1, '<=', 2 assert_evaluates_true 1, '<=', 2
assert_evalutes_true 1, '<=', 1 assert_evaluates_true 1, '<=', 1
# negative numbers # negative numbers
assert_evalutes_true 1, '>', -1 assert_evaluates_true 1, '>', -1
assert_evalutes_true -1, '<', 1 assert_evaluates_true (-1), '<', 1
assert_evalutes_true 1.0, '>', -1.0 assert_evaluates_true 1.0, '>', -1.0
assert_evalutes_true -1.0, '<', 1.0 assert_evaluates_true (-1.0), '<', 1.0
end end
def test_default_operators_evalute_false def test_default_operators_evalute_false
assert_evalutes_false 1, '==', 2 assert_evaluates_false 1, '==', 2
assert_evalutes_false 1, '!=', 1 assert_evaluates_false 1, '!=', 1
assert_evalutes_false 1, '<>', 1 assert_evaluates_false 1, '<>', 1
assert_evalutes_false 1, '<', 0 assert_evaluates_false 1, '<', 0
assert_evalutes_false 2, '>', 4 assert_evaluates_false 2, '>', 4
assert_evalutes_false 1, '>=', 3 assert_evaluates_false 1, '>=', 3
assert_evalutes_false 2, '>=', 4 assert_evaluates_false 2, '>=', 4
assert_evalutes_false 1, '<=', 0 assert_evaluates_false 1, '<=', 0
assert_evalutes_false 1, '<=', 0 assert_evaluates_false 1, '<=', 0
end end
def test_contains_works_on_strings def test_contains_works_on_strings
assert_evalutes_true 'bob', 'contains', 'o' assert_evaluates_true 'bob', 'contains', 'o'
assert_evalutes_true 'bob', 'contains', 'b' assert_evaluates_true 'bob', 'contains', 'b'
assert_evalutes_true 'bob', 'contains', 'bo' assert_evaluates_true 'bob', 'contains', 'bo'
assert_evalutes_true 'bob', 'contains', 'ob' assert_evaluates_true 'bob', 'contains', 'ob'
assert_evalutes_true 'bob', 'contains', 'bob' assert_evaluates_true 'bob', 'contains', 'bob'
assert_evalutes_false 'bob', 'contains', 'bob2' assert_evaluates_false 'bob', 'contains', 'bob2'
assert_evalutes_false 'bob', 'contains', 'a' assert_evaluates_false 'bob', 'contains', 'a'
assert_evalutes_false 'bob', 'contains', '---' assert_evaluates_false 'bob', 'contains', '---'
end end
def test_invalid_comparation_operator def test_invalid_comparation_operator
@@ -60,34 +64,42 @@ class ConditionUnitTest < Minitest::Test
assert_evaluates_argument_error '1', '<=', 0 assert_evaluates_argument_error '1', '<=', 0
end end
def test_hash_compare_backwards_compatibility
assert_nil Condition.new({}, '>', 2).evaluate
assert_nil Condition.new(2, '>', {}).evaluate
assert_equal false, Condition.new({}, '==', 2).evaluate
assert_equal true, Condition.new({ 'a' => 1 }, '==', { 'a' => 1 }).evaluate
assert_equal true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate
end
def test_contains_works_on_arrays def test_contains_works_on_arrays
@context = Liquid::Context.new @context = Liquid::Context.new
@context['array'] = [1, 2, 3, 4, 5] @context['array'] = [1, 2, 3, 4, 5]
array_expr = VariableLookup.new("array") array_expr = VariableLookup.new("array")
assert_evalutes_false array_expr, 'contains', 0 assert_evaluates_false array_expr, 'contains', 0
assert_evalutes_true array_expr, 'contains', 1 assert_evaluates_true array_expr, 'contains', 1
assert_evalutes_true array_expr, 'contains', 2 assert_evaluates_true array_expr, 'contains', 2
assert_evalutes_true array_expr, 'contains', 3 assert_evaluates_true array_expr, 'contains', 3
assert_evalutes_true array_expr, 'contains', 4 assert_evaluates_true array_expr, 'contains', 4
assert_evalutes_true array_expr, 'contains', 5 assert_evaluates_true array_expr, 'contains', 5
assert_evalutes_false array_expr, 'contains', 6 assert_evaluates_false array_expr, 'contains', 6
assert_evalutes_false array_expr, 'contains', "1" assert_evaluates_false array_expr, 'contains', "1"
end end
def test_contains_returns_false_for_nil_operands def test_contains_returns_false_for_nil_operands
@context = Liquid::Context.new @context = Liquid::Context.new
assert_evalutes_false VariableLookup.new('not_assigned'), 'contains', '0' assert_evaluates_false VariableLookup.new('not_assigned'), 'contains', '0'
assert_evalutes_false 0, 'contains', VariableLookup.new('not_assigned') assert_evaluates_false 0, 'contains', VariableLookup.new('not_assigned')
end end
def test_contains_return_false_on_wrong_data_type def test_contains_return_false_on_wrong_data_type
assert_evalutes_false 1, 'contains', 0 assert_evaluates_false 1, 'contains', 0
end end
def test_contains_with_string_left_operand_coerces_right_operand_to_string def test_contains_with_string_left_operand_coerces_right_operand_to_string
assert_evalutes_true ' 1 ', 'contains', 1 assert_evaluates_true ' 1 ', 'contains', 1
assert_evalutes_false ' 1 ', 'contains', 2 assert_evaluates_false ' 1 ', 'contains', 2
end end
def test_or_condition def test_or_condition
@@ -121,8 +133,8 @@ class ConditionUnitTest < Minitest::Test
def test_should_allow_custom_proc_operator def test_should_allow_custom_proc_operator
Condition.operators['starts_with'] = proc { |cond, left, right| left =~ %r{^#{right}} } Condition.operators['starts_with'] = proc { |cond, left, right| left =~ %r{^#{right}} }
assert_evalutes_true 'bob', 'starts_with', 'b' assert_evaluates_true 'bob', 'starts_with', 'b'
assert_evalutes_false 'bob', 'starts_with', 'o' assert_evaluates_false 'bob', 'starts_with', 'o'
ensure ensure
Condition.operators.delete 'starts_with' Condition.operators.delete 'starts_with'
end end
@@ -131,24 +143,24 @@ class ConditionUnitTest < Minitest::Test
@context = Liquid::Context.new @context = Liquid::Context.new
@context['one'] = @context['another'] = "gnomeslab-and-or-liquid" @context['one'] = @context['another'] = "gnomeslab-and-or-liquid"
assert_evalutes_true VariableLookup.new("one"), '==', VariableLookup.new("another") assert_evaluates_true VariableLookup.new("one"), '==', VariableLookup.new("another")
end end
private private
def assert_evalutes_true(left, op, right) def assert_evaluates_true(left, op, right)
assert Condition.new(left, op, right).evaluate(@context || Liquid::Context.new), assert Condition.new(left, op, right).evaluate(@context),
"Evaluated false: #{left} #{op} #{right}" "Evaluated false: #{left} #{op} #{right}"
end end
def assert_evalutes_false(left, op, right) def assert_evaluates_false(left, op, right)
assert !Condition.new(left, op, right).evaluate(@context || Liquid::Context.new), assert !Condition.new(left, op, right).evaluate(@context),
"Evaluated true: #{left} #{op} #{right}" "Evaluated true: #{left} #{op} #{right}"
end end
def assert_evaluates_argument_error(left, op, right) def assert_evaluates_argument_error(left, op, right)
assert_raises(Liquid::ArgumentError) do assert_raises(Liquid::ArgumentError) do
Condition.new(left, op, right).evaluate(@context || Liquid::Context.new) Condition.new(left, op, right).evaluate(@context)
end end
end end
end # ConditionTest end # ConditionTest

View File

@@ -70,10 +70,6 @@ class ContextUnitTest < Minitest::Test
@context = Liquid::Context.new @context = Liquid::Context.new
end end
def teardown
Spy.teardown
end
def test_variables def test_variables
@context['string'] = 'string' @context['string'] = 'string'
assert_equal 'string', @context['string'] assert_equal 'string', @context['string']
@@ -98,12 +94,12 @@ class ContextUnitTest < Minitest::Test
assert_equal false, @context['bool'] assert_equal false, @context['bool']
@context['nil'] = nil @context['nil'] = nil
assert_equal nil, @context['nil'] assert_nil @context['nil']
assert_equal nil, @context['nil'] assert_nil @context['nil']
end end
def test_variables_not_existing def test_variables_not_existing
assert_equal nil, @context['does_not_exist'] assert_nil @context['does_not_exist']
end end
def test_scoping def test_scoping
@@ -185,7 +181,7 @@ class ContextUnitTest < Minitest::Test
@context['test'] = 'test' @context['test'] = 'test'
assert_equal 'test', @context['test'] assert_equal 'test', @context['test']
@context.pop @context.pop
assert_equal nil, @context['test'] assert_nil @context['test']
end end
def test_hierachical_data def test_hierachical_data
@@ -300,7 +296,7 @@ class ContextUnitTest < Minitest::Test
@context['hash'] = { 'first' => 'Hello' } @context['hash'] = { 'first' => 'Hello' }
assert_equal 1, @context['array.first'] assert_equal 1, @context['array.first']
assert_equal nil, @context['array["first"]'] assert_nil @context['array["first"]']
assert_equal 'Hello', @context['hash["first"]'] assert_equal 'Hello', @context['hash["first"]']
end end
@@ -450,14 +446,10 @@ class ContextUnitTest < Minitest::Test
assert_equal @context, @context['category'].context assert_equal @context, @context['category'].context
end end
def test_use_empty_instead_of_any_in_interrupt_handling_to_avoid_lots_of_unnecessary_object_allocations def test_interrupt_avoids_object_allocations
mock_any = Spy.on_instance_method(Array, :any?) assert_no_object_allocations do
mock_empty = Spy.on_instance_method(Array, :empty?) @context.interrupt?
end
@context.interrupt?
refute mock_any.has_been_called?
assert mock_empty.has_been_called?
end end
def test_context_initialization_with_a_proc_in_environment def test_context_initialization_with_a_proc_in_environment
@@ -480,4 +472,18 @@ class ContextUnitTest < Minitest::Test
context = Context.new context = Context.new
assert_equal 'hi', context.apply_global_filter('hi') assert_equal 'hi', context.apply_global_filter('hi')
end end
private
def assert_no_object_allocations
unless RUBY_ENGINE == 'ruby'
skip "stackprof needed to count object allocations"
end
require 'stackprof'
profile = StackProf.run(mode: :object) do
yield
end
assert_equal 0, profile[:samples]
end
end # ContextTest end # ContextTest

View File

@@ -19,7 +19,7 @@ class LexerUnitTest < Minitest::Test
end end
def test_comparison def test_comparison
tokens = Lexer.new('== <> contains').tokenize tokens = Lexer.new('== <> contains ').tokenize
assert_equal [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokens assert_equal [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokens
end end

View File

@@ -77,4 +77,88 @@ class StrainerUnitTest < Minitest::Test
assert_kind_of b, strainer assert_kind_of b, strainer
assert_kind_of Liquid::StandardFilters, strainer assert_kind_of Liquid::StandardFilters, strainer
end end
def test_add_filter_when_wrong_filter_class
c = Context.new
s = c.strainer
wrong_filter = ->(v) { v.reverse }
assert_raises ArgumentError do
s.class.add_filter(wrong_filter)
end
end
module PrivateMethodOverrideFilter
private
def public_filter
"overriden as private"
end
end
def test_add_filter_raises_when_module_privately_overrides_registered_public_methods
strainer = Context.new.strainer
error = assert_raises(Liquid::MethodOverrideError) do
strainer.class.add_filter(PrivateMethodOverrideFilter)
end
assert_equal 'Liquid error: Filter overrides registered public methods as non public: public_filter', error.message
end
module ProtectedMethodOverrideFilter
protected
def public_filter
"overriden as protected"
end
end
def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected
strainer = Context.new.strainer
error = assert_raises(Liquid::MethodOverrideError) do
strainer.class.add_filter(ProtectedMethodOverrideFilter)
end
assert_equal 'Liquid error: Filter overrides registered public methods as non public: public_filter', error.message
end
module PublicMethodOverrideFilter
def public_filter
"public"
end
end
def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method
strainer = Context.new.strainer
strainer.class.add_filter(PublicMethodOverrideFilter)
assert strainer.class.filter_methods.include?('public_filter')
end
module LateAddedFilter
def late_added_filter(input)
"filtered"
end
end
def test_global_filter_clears_cache
assert_equal 'input', Strainer.create(nil).invoke('late_added_filter', 'input')
Strainer.global_filter(LateAddedFilter)
assert_equal 'filtered', Strainer.create(nil).invoke('late_added_filter', 'input')
end
def test_add_filter_does_not_include_already_included_module
mod = Module.new do
class << self
attr_accessor :include_count
def included(mod)
self.include_count += 1
end
end
self.include_count = 0
end
strainer = Context.new.strainer
strainer.class.add_filter(mod)
strainer.class.add_filter(mod)
assert_equal 1, mod.include_count
end
end # StrainerTest end # StrainerTest

View File

@@ -67,4 +67,12 @@ class TemplateUnitTest < Minitest::Test
Template.tags.delete('fake') Template.tags.delete('fake')
assert_nil Template.tags['fake'] assert_nil Template.tags['fake']
end end
def test_tags_can_be_looped_over
Template.register_tag('fake', FakeTag)
result = Template.tags.map { |name, klass| [name, klass] }
assert result.include?(["fake", "TemplateUnitTest::FakeTag"])
ensure
Template.tags.delete('fake')
end
end end