Compare commits

..
Author SHA1 Message Date
Andy Waite c24d3dab32 wip 2022-03-14 13:01:10 -04:00
Andy Waite dc3162fcd2 wip 2022-03-14 12:58:53 -04:00
Andy Waite fbe086963c wip 2022-03-14 12:56:37 -04:00
Andy Waite 378a455830 wip 2022-03-14 12:46:45 -04:00
Andy Waite 2ee44d1341 wip 2022-03-14 12:44:51 -04:00
Andy Waite 6e2939cbaf wip 2022-03-14 12:41:37 -04:00
Thierry JoyalandGitHub 7357dcf185 Merge pull request #1536 from Shopify/flaky-profiler-test-v2
Add artificial execution time in profiler tests
2022-03-07 10:06:13 -05:00
Thierry JoyalandGitHub 68c3827ef2 Merge pull request #1525 from Shopify/standardfilter/fix-missing-context-on-iterations
[StandardFilter] Fix missing @context on iterations
2022-03-07 09:19:18 -05:00
Thierry Joyal 4af38bc549 [StandardFilter] Fix missing @context on iterations 2022-03-07 09:17:07 -05:00
Thierry Joyal df241abf70 Add artificial execution time in profiler tests 2022-03-07 08:50:03 -05:00
10f8337209 Test Ruby 3.1 in CI (#1533)
Co-authored-by: Dylan Thacker-Smith <[email protected]>
2022-03-04 13:23:04 -05:00
Thierry JoyalandGitHub 5ed0410a8b Merge pull request #1534 from Shopify/context-test-cleanup
Context test cleanup
2022-03-04 11:31:00 -05:00
Thierry Joyal 0f5220c391 ContextTest: Classes to use appropriate ancestor 2022-03-04 09:14:57 -05:00
Thierry Joyal 7a23f46fab ContextTest: Cleanup global variable assignments 2022-03-04 09:10:38 -05:00
Peter ZhuandGitHub 3f7edf00b9 Merge pull request #1531 from Shopify/pz-array-fetch-warning
Fix warning about block and default value
2022-03-02 16:25:23 -05:00
Thierry JoyalandGitHub b4a2a79e26 Merge pull request #1527 from Shopify/condition/receive-mandatory-context-argument
Condition#evaluate to receive mandatory context argument
2022-03-02 15:04:24 -05:00
Thierry Joyal 1d2bee1f60 Condition#evaluate to receive mandatory context argument 2022-03-02 14:35:31 -05:00
Peter Zhu 01e6eec97a Fix warning about block and default value
Ruby's Array#fetch accepts either a default value or a block, but not
both. If both are passed in, then it uses the block and outputs this
warning:

```
lib/liquid/static_registers.rb:34: warning: block supersedes default value argument
```
2022-03-02 14:33:12 -05:00
Jean Boussier fbdab19358 We're in 2022... 2022-03-02 18:27:25 +01:00
Thierry JoyalandGitHub ce85ac5d3d Merge pull request #1529 from Shopify/tests/standard-filters-with-context
StandardFiltersTest: Initialize following production code paths with context
2022-03-02 08:44:16 -05:00
Thierry Joyal c0ffee16a3 StandardFiltersTest: Initialize following production code paths with context 2022-03-01 16:01:00 +00:00
Jean Boussier a7eb33fa39 Release 5.2.0 2022-03-01 16:18:49 +01:00
Jean byroot BoussierandGitHub 1a85e98793 Merge pull request #1524 from Shopify/global-constant-cache
Eagerly cache global filters
2022-03-01 16:14:31 +01:00
Jean Boussier c588337aac Eagerly cache global filters
Including a module can cause Ruby's global constant cache to be busted
if the included module contain constants. So that's something you don't
want to happen at "runtime", otherwise it will severely degrade performance
and if you are using YJIT or MJIT most of the compiled code will be invalidated.

To limit the impact of this, we can pre-include the global filters,
as they're generally registered during boot, that limits the problem
to non-global filters.
2022-03-01 13:40:40 +01:00
Dylan Thacker-SmithandGitHub 97f7922457 Add missing changelog entry for PR #1518 (#1521) 2022-02-24 14:46:26 -05:00
0d83e64cfe Add replace_last and remove_last filters (#1422)
Co-authored-by: ADTC <[email protected]>
Co-authored-by: Dylan Thacker-Smith <[email protected]>
2022-02-24 14:02:15 -05:00
Dylan Thacker-SmithandGitHub 0d5e01ae98 Fix some internal errors in filters from invalid input. (#1476)
These fixes came from improving the corresponding test, so these might not
actually be causing problems in practice.
2022-02-24 09:17:37 -05:00
Charles-Philippe ClermontandGitHub 15eaa49e48 Merge pull request #1518 from Shopify/fix/kwarg-key-name-liquid-c-inconsistency
Fix kwarg parsing inconsistency with Liquid::C
2022-02-14 13:22:12 -05:00
Tobias LütkeandGitHub 91c54c579d Merge pull request #1477 from Watson1978/performance
Increase parsing performance
2022-02-14 12:25:19 -05:00
Watson ebdfdb80e5 Detect quoted string using String#{start_with?, end_with?} to reduce Regexp#=== calling 2021-09-26 04:30:49 +09:00
Watson 95e9fa5010 Use String#=~ and Regexp.last_match instead to retrieve the markup content
If the first value is only used obtained with String#scan,
it will increase the performance if replace with `String#=~` and `Regexp.last_match`.

### Environment
- MacBook Air (M1, 2020)
- macOS 12.0 beta 7
- Apple M1
- Ruby 3.0.2

### Test code
```ruby
require 'benchmark/ips'

WhitespaceControl           = '-'
VariableStart               = /\{\{/
VariableEnd                 = /\}\}/

ContentOfVariable   = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om
token = "{{item.product.featured_image | product_img_url: 'thumb' }}"

Benchmark.ips do |x|
  x.report("String#scan")  { token.scan(ContentOfVariable) {|content| break }  }
  x.report("String#match") { m = token.match(ContentOfVariable); m[1] }
  x.report("String#=~")    { token =~ ContentOfVariable; Regexp.last_match(1) }

  x.compare!
end
```

### Result
```
Warming up --------------------------------------
         String#scan   135.724k i/100ms
        String#match   117.397k i/100ms
           String#=~   151.637k i/100ms
Calculating -------------------------------------
         String#scan      1.351M (± 0.8%) i/s -      6.786M in   5.021955s
        String#match      1.169M (± 1.3%) i/s -      5.870M in   5.020429s
           String#=~      1.520M (± 0.9%) i/s -      7.733M in   5.087427s

Comparison:
           String#=~:  1520250.9 i/s
         String#scan:  1351399.0 i/s - 1.12x  (± 0.00) slower
        String#match:  1169384.1 i/s - 1.30x  (± 0.00) slower
```
2021-09-26 04:12:53 +09:00
24 changed files with 330 additions and 132 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
matrix: matrix:
entry: entry:
- { ruby: 2.5, allowed-failure: false } # minimum supported - { ruby: 2.5, allowed-failure: false } # minimum supported
- { ruby: 3.0, allowed-failure: false } # latest - { ruby: 3.1, allowed-failure: false } # latest
- { ruby: ruby-head, allowed-failure: true } - { ruby: ruby-head, allowed-failure: true }
name: test (${{ matrix.entry.ruby }}) name: test (${{ matrix.entry.ruby }})
steps: steps:
+18
View File
@@ -1,5 +1,23 @@
# Liquid Change Log # Liquid Change Log
## 5.3.0 (unreleased)
### Fixes
* StandardFilter: Fix missing @context on iterations (#1525) [Thierry Joyal]
### Deprecation
* Condition#evaluate to require mandatory context argument in Liquid 6.0.0 (#1527) [Thierry Joyal]
## 5.2.0 2022-03-01
### Features
* Add `remove_last`, and `replace_last` filters (#1422) [Anders Hagbard]
* Eagerly cache global filters (#1524) [Jean Boussier]
### Fixes
* Fix some internal errors in filters from invalid input (#1476) [Dylan Thacker-Smith]
* Allow dash in filter kwarg name for consistency with Liquid::C (#1518) [CP Clermont]
## 5.1.0 / 2021-09-09 ## 5.1.0 / 2021-09-09
### Features ### Features
+1 -1
View File
@@ -59,8 +59,8 @@ require 'liquid/forloop_drop'
require 'liquid/extensions' require 'liquid/extensions'
require 'liquid/errors' require 'liquid/errors'
require 'liquid/interrupts' require 'liquid/interrupts'
require 'liquid/strainer_factory'
require 'liquid/strainer_template' require 'liquid/strainer_template'
require 'liquid/strainer_factory'
require 'liquid/expression' require 'liquid/expression'
require 'liquid/context' require 'liquid/context'
require 'liquid/parser_switching' require 'liquid/parser_switching'
+2 -2
View File
@@ -231,8 +231,8 @@ module Liquid
end end
def create_variable(token, parse_context) def create_variable(token, parse_context)
token.scan(ContentOfVariable) do |content| if token =~ ContentOfVariable
markup = content.first markup = Regexp.last_match(1)
return Variable.new(markup, parse_context) return Variable.new(markup, parse_context)
end end
BlockBody.raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context)
+7 -1
View File
@@ -61,7 +61,7 @@ module Liquid
@child_condition = nil @child_condition = nil
end end
def evaluate(context = Context.new) def evaluate(context = deprecated_default_context)
condition = self condition = self
result = nil result = nil
loop do loop do
@@ -150,6 +150,12 @@ module Liquid
end end
end end
def deprecated_default_context
warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
" and will be removed from Liquid 6.0.0.")
Context.new
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children def children
[ [
+11 -10
View File
@@ -10,21 +10,23 @@ module Liquid
'empty' => '' 'empty' => ''
}.freeze }.freeze
SINGLE_QUOTED_STRING = /\A\s*'(.*)'\s*\z/m INTEGERS_REGEX = /\A(-?\d+)\z/
DOUBLE_QUOTED_STRING = /\A\s*"(.*)"\s*\z/m FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
INTEGERS_REGEX = /\A\s*(-?\d+)\s*\z/
FLOATS_REGEX = /\A\s*(-?\d[\d\.]+)\s*\z/
# Use an atomic group (?>...) to avoid pathological backtracing from # Use an atomic group (?>...) to avoid pathological backtracing from
# malicious input as described in https://github.com/Shopify/liquid/issues/1357 # malicious input as described in https://github.com/Shopify/liquid/issues/1357
RANGES_REGEX = /\A\s*\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\s*\z/ RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
def self.parse(markup) def self.parse(markup)
return nil unless markup
markup = markup.strip
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
end
case markup case markup
when nil
nil
when SINGLE_QUOTED_STRING, DOUBLE_QUOTED_STRING
Regexp.last_match(1)
when INTEGERS_REGEX when INTEGERS_REGEX
Regexp.last_match(1).to_i Regexp.last_match(1).to_i
when RANGES_REGEX when RANGES_REGEX
@@ -32,7 +34,6 @@ module Liquid
when FLOATS_REGEX when FLOATS_REGEX
Regexp.last_match(1).to_f Regexp.last_match(1).to_f
else else
markup = markup.strip
if LITERALS.key?(markup) if LITERALS.key?(markup)
LITERALS[markup] LITERALS[markup]
else else
+57 -18
View File
@@ -213,17 +213,23 @@ module Liquid
if ary.empty? if ary.empty?
[] []
elsif ary.first.respond_to?(:[]) && target_value.nil? elsif target_value.nil?
begin ary.select do |item|
ary.select { |item| item[property] } item[property]
rescue TypeError rescue TypeError
raise_property_error(property) raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end end
elsif ary.first.respond_to?(:[]) else
begin ary.select do |item|
ary.select { |item| item[property] == target_value } item[property] == target_value
rescue TypeError rescue TypeError
raise_property_error(property) raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end end
end end
end end
@@ -237,11 +243,14 @@ module Liquid
ary.uniq ary.uniq
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?(:[]) else
begin ary.uniq do |item|
ary.uniq { |a| a[property] } item[property]
rescue TypeError rescue TypeError
raise_property_error(property) raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end end
end end
end end
@@ -277,11 +286,14 @@ module Liquid
ary.compact ary.compact
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?(:[]) else
begin ary.reject do |item|
ary.reject { |a| a[property].nil? } item[property].nil?
rescue TypeError rescue TypeError
raise_property_error(property) raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end end
end end
end end
@@ -296,14 +308,34 @@ module Liquid
input.to_s.sub(string.to_s, replacement.to_s) input.to_s.sub(string.to_s, replacement.to_s)
end end
# Replace the last occurrences of a string with another
def replace_last(input, string, replacement)
input = input.to_s
string = string.to_s
replacement = replacement.to_s
start_index = input.rindex(string)
return input unless start_index
output = input.dup
output[start_index, string.length] = replacement
output
end
# remove a substring # remove a substring
def remove(input, string) def remove(input, string)
input.to_s.gsub(string.to_s, '') replace(input, string, '')
end end
# remove the first occurrences of a substring # remove the first occurrences of a substring
def remove_first(input, string) def remove_first(input, string)
input.to_s.sub(string.to_s, '') replace_first(input, string, '')
end
# remove the last occurences of a substring
def remove_last(input, string)
replace_last(input, string, '')
end end
# add one string to another # add one string to another
@@ -486,10 +518,16 @@ module Liquid
end end
def nil_safe_compare(a, b) def nil_safe_compare(a, b)
if !a.nil? && !b.nil? result = a <=> b
a <=> b
if result
result
elsif a.nil?
1
elsif b.nil?
-1
else else
a.nil? ? 1 : -1 raise Liquid::ArgumentError, "cannot sort values of incompatible types"
end end
end end
@@ -544,8 +582,9 @@ module Liquid
def each def each
@input.each do |e| @input.each do |e|
e = e.respond_to?(:to_liquid) ? e.to_liquid : e
e.context = @context if e.respond_to?(:context=) e.context = @context if e.respond_to?(:context=)
yield(e.respond_to?(:to_liquid) ? e.to_liquid : e) yield(e)
end end
end end
end end
+5 -1
View File
@@ -31,7 +31,11 @@ module Liquid
if @registers.key?(key) if @registers.key?(key)
@registers.fetch(key) @registers.fetch(key)
elsif default != UNDEFINED elsif default != UNDEFINED
@static.fetch(key, default, &block) if block_given?
@static.fetch(key, &block)
else
@static.fetch(key, default)
end
else else
@static.fetch(key, &block) @static.fetch(key, &block)
end end
+11 -10
View File
@@ -7,25 +7,26 @@ module Liquid
def add_global_filter(filter) def add_global_filter(filter)
strainer_class_cache.clear strainer_class_cache.clear
global_filters << filter GlobalCache.add_filter(filter)
end end
def create(context, filters = []) def create(context, filters = [])
strainer_from_cache(filters).new(context) strainer_from_cache(filters).new(context)
end end
GlobalCache = Class.new(StrainerTemplate)
private private
def global_filters
@global_filters ||= []
end
def strainer_from_cache(filters) def strainer_from_cache(filters)
strainer_class_cache[filters] ||= begin if filters.empty?
klass = Class.new(StrainerTemplate) GlobalCache
global_filters.each { |f| klass.add_filter(f) } else
filters.each { |f| klass.add_filter(f) } strainer_class_cache[filters] ||= begin
klass klass = Class.new(GlobalCache)
filters.each { |f| klass.add_filter(f) }
klass
end
end end
end end
+5
View File
@@ -31,6 +31,11 @@ module Liquid
filter_methods.include?(method.to_s) filter_methods.include?(method.to_s)
end end
def inherited(subclass)
super
subclass.instance_variable_set(:@filter_methods, @filter_methods.dup)
end
private private
def filter_methods def filter_methods
+11
View File
@@ -1,6 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
# @public_docs
module Liquid module Liquid
# @public_docs
# @title Case
# @syntax The syntax
# @summary The summary
# @type tag
# @description
# Creates a switch statement to execute a particular block of code when a variable has a specified value.
# `case` initializes the switch statement, and `when` statements define the various conditions.
#
# An optional `else` statement at the end of the case provides code to execute if none of the conditions are met.
class Case < Block class Case < Block
Syntax = /(#{QuotedFragment})/o Syntax = /(#{QuotedFragment})/o
WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om
+11
View File
@@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# @public_docs
module Liquid module Liquid
# Cycle is usually used within a loop to alternate between values, like colors or DOM classes. # Cycle is usually used within a loop to alternate between values, like colors or DOM classes.
# #
@@ -13,6 +14,16 @@ module Liquid
# <div class="red"> Item four </div> # <div class="red"> Item four </div>
# <div class="green"> Item five</div> # <div class="green"> Item five</div>
# #
# @public_docs
# @title Cycle
# @syntax The syntax
# @summary Loops through a group of strings and prints them in the order that they were passed as arguments.
# @type tag
# @description
# Loops through a group of strings and prints them in the order that they were passed as arguments.
# Each time `cycle`` is called, the next string argument is printed.
#
# `cycle` must be used within a `for`` loop block.
class Cycle < Tag class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
+7
View File
@@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# @public_docs
module Liquid module Liquid
# "For" iterates over an array or collection. # "For" iterates over an array or collection.
# Several useful variables are available to you within the loop. # Several useful variables are available to you within the loop.
@@ -45,6 +46,12 @@ module Liquid
# forloop.last:: Returns true if the item is the last item. # forloop.last:: Returns true if the item is the last item.
# forloop.parentloop:: Provides access to the parent loop, if present. # forloop.parentloop:: Provides access to the parent loop, if present.
# #
# @public_docs
# @title For
# @syntax The syntax
# @summary Repeatedly executes a block of code.
# @type tag
# @description The description
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
+14 -6
View File
@@ -1,16 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
# @public_docs
module Liquid module Liquid
# If is the conditional block # If is the conditional block
# #
# {% if user.admin %} # @public_docs
# Admin user! # @title If
# {% else %} # @syntax The syntax
# Not admin user # @summary Executes a block of code only if a certain condition is `true`.
# {% endif %} # @type tag
# @description
# If is the conditional block
# #
# There are {% if count < 5 %} less {% else %} more {% endif %} items than you need. # {% if user.admin %}
# Admin user!
# {% else %}
# Not admin user
# {% endif %}
# #
# There are {% if count < 5 %} less {% else %} more {% endif %} items than you need.
class If < Block class If < Block
Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o
ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o
+9
View File
@@ -1,6 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
# @public_docs
module Liquid module Liquid
# @public_docs
# @title Tablerow
# @syntax The syntax
# @summary Generates an HTML table.
# @type tag
# @description
# Generates an HTML table. Must be wrapped in opening `<table>` and closing `</table>` HTML tags.
# For a full list of attributes available within a `tablerow` loop, see `tablerow` (object).
class TableRow < Block class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
+9 -3
View File
@@ -2,11 +2,17 @@
require_relative 'if' require_relative 'if'
# @public_docs
module Liquid module Liquid
# Unless is a conditional just like 'if' but works on the inverse logic. # @public_docs
# # @title Unless
# {% unless x < 0 %} x is greater than zero {% endunless %} # @syntax The syntax
# @summary The opposite of `if`` – executes a block of code only if a certain condition is not met.
# @type tag
# @description
# Unless is a conditional just like 'if' but works on the inverse logic.
# #
# {% unless x < 0 %} x is greater than zero {% endunless %}
class Unless < If class Unless < If
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
# First condition is interpreted backwards ( if not ) # First condition is interpreted backwards ( if not )
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Liquid module Liquid
VERSION = "5.1.0" VERSION = "5.3.0.alpha"
end end
+12 -14
View File
@@ -24,7 +24,7 @@ class ContextSensitiveDrop < Liquid::Drop
end end
end end
class Category < Liquid::Drop class Category
attr_accessor :name attr_accessor :name
def initialize(name) def initialize(name)
@@ -36,8 +36,9 @@ class Category < Liquid::Drop
end end
end end
class CategoryDrop class CategoryDrop < Liquid::Drop
attr_accessor :category, :context attr_accessor :category, :context
def initialize(category) def initialize(category)
@category = category @category = category
end end
@@ -405,45 +406,42 @@ class ContextTest < Minitest::Test
end end
def test_lambda_is_called_once def test_lambda_is_called_once
@global = 0
@context['callcount'] = proc { @context['callcount'] = proc {
@global ||= 0 @global += 1
@global += 1
@global.to_s @global.to_s
} }
assert_equal('1', @context['callcount']) assert_equal('1', @context['callcount'])
assert_equal('1', @context['callcount']) assert_equal('1', @context['callcount'])
assert_equal('1', @context['callcount']) assert_equal('1', @context['callcount'])
@global = nil
end end
def test_nested_lambda_is_called_once def test_nested_lambda_is_called_once
@global = 0
@context['callcount'] = { "lambda" => proc { @context['callcount'] = { "lambda" => proc {
@global ||= 0 @global += 1
@global += 1
@global.to_s @global.to_s
} } } }
assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda'])
assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda'])
assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda'])
@global = nil
end end
def test_lambda_in_array_is_called_once def test_lambda_in_array_is_called_once
@global = 0
@context['callcount'] = [1, 2, proc { @context['callcount'] = [1, 2, proc {
@global ||= 0 @global += 1
@global += 1
@global.to_s @global.to_s
}, 4, 5] }, 4, 5]
assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]'])
assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]'])
assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]'])
@global = nil
end end
def test_access_to_context_from_proc def test_access_to_context_from_proc
+33 -6
View File
@@ -3,6 +3,27 @@
require 'test_helper' require 'test_helper'
class ProfilerTest < Minitest::Test class ProfilerTest < Minitest::Test
class TestDrop < Liquid::Drop
def initialize(value)
super()
@value = value
end
def to_s
artificial_execution_time
@value
end
private
# Monotonic clock precision fluctuate based on the operating system
# By introducing a small sleep we ensure ourselves to register a non zero unit of time
def artificial_execution_time
sleep(Process.clock_getres(Process::CLOCK_MONOTONIC))
end
end
include Liquid include Liquid
class ProfilingFileSystem class ProfilingFileSystem
@@ -198,16 +219,22 @@ class ProfilerTest < Minitest::Test
def test_profiling_supports_self_time def test_profiling_supports_self_time
t = Template.parse("{% for item in collection %} {{ item }} {% endfor %}", profile: true) t = Template.parse("{% for item in collection %} {{ item }} {% endfor %}", profile: true)
t.render!("collection" => ["one", "two"]) collection = [
leaf = t.profiler[0].children[0] TestDrop.new("one"),
TestDrop.new("two"),
]
output = t.render!("collection" => collection)
assert_equal(" one two ", output)
assert_operator(leaf.self_time, :>, 0) leaf = t.profiler[0].children[0]
assert_operator(leaf.self_time, :>, 0.0)
end end
def test_profiling_supports_total_time def test_profiling_supports_total_time
t = Template.parse("{% if true %} {% increment test %} {{ test }} {% endif %}", profile: true) t = Template.parse("{% if true %} {{ test }} {% endif %}", profile: true)
t.render! output = t.render!("test" => TestDrop.new("one"))
assert_equal(" one ", output)
assert_operator(t.profiler[0].total_time, :>, 0) assert_operator(t.profiler[0].total_time, :>, 0.0)
end end
end end
+65 -32
View File
@@ -3,10 +3,6 @@
require 'test_helper' require 'test_helper'
class Filters
include Liquid::StandardFilters
end
class TestThing class TestThing
attr_reader :foo attr_reader :foo
@@ -29,8 +25,24 @@ class TestThing
end end
class TestDrop < Liquid::Drop class TestDrop < Liquid::Drop
def test def initialize(value:)
"testfoo" @value = value
end
attr_reader :value
def registers
@context.registers
end
end
class TestModel
def initialize(value:)
@value = value
end
def to_liquid
TestDrop.new(value: @value)
end end
end end
@@ -53,10 +65,13 @@ class NumberLikeThing < Liquid::Drop
end end
class StandardFiltersTest < Minitest::Test class StandardFiltersTest < Minitest::Test
Filters = Class.new(Liquid::StrainerTemplate)
Filters.add_filter(Liquid::StandardFilters)
include Liquid include Liquid
def setup def setup
@filters = Filters.new @filters = Filters.new(Context.new)
end end
def test_size def test_size
@@ -259,8 +274,8 @@ class StandardFiltersTest < Minitest::Test
{ "price" => 1, "handle" => "gamma" }, { "price" => 1, "handle" => "gamma" },
{ "price" => 2, "handle" => "epsilon" }, { "price" => 2, "handle" => "epsilon" },
{ "price" => 4, "handle" => "alpha" }, { "price" => 4, "handle" => "alpha" },
{ "handle" => "delta" },
{ "handle" => "beta" }, { "handle" => "beta" },
{ "handle" => "delta" },
] ]
assert_equal(expectation, @filters.sort(input, "price")) assert_equal(expectation, @filters.sort(input, "price"))
end end
@@ -363,8 +378,9 @@ class StandardFiltersTest < Minitest::Test
assert_equal(["foo"], @filters.uniq("foo")) assert_equal(["foo"], @filters.uniq("foo"))
assert_equal([1, 3, 2, 4], @filters.uniq([1, 1, 3, 2, 3, 1, 4, 3, 2, 1])) assert_equal([1, 3, 2, 4], @filters.uniq([1, 1, 3, 2, 3, 1, 4, 3, 2, 1]))
assert_equal([{ "a" => 1 }, { "a" => 3 }, { "a" => 2 }], @filters.uniq([{ "a" => 1 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a")) assert_equal([{ "a" => 1 }, { "a" => 3 }, { "a" => 2 }], @filters.uniq([{ "a" => 1 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
testdrop = TestDrop.new test_drop = TestDrop.new(value: "test")
assert_equal([testdrop], @filters.uniq([testdrop, TestDrop.new], 'test')) test_drop_alternate = TestDrop.new(value: "test")
assert_equal([test_drop], @filters.uniq([test_drop, test_drop_alternate], 'value'))
end end
def test_uniq_empty_array def test_uniq_empty_array
@@ -423,6 +439,16 @@ class StandardFiltersTest < Minitest::Test
assert_template_result("woot: 1", '{{ foo | map: "whatever" }}', "foo" => [t]) assert_template_result("woot: 1", '{{ foo | map: "whatever" }}', "foo" => [t])
end end
def test_map_calls_context=
model = TestModel.new(value: "test")
template = Template.parse('{{ foo | map: "registers" }}')
template.registers[:test] = 1234
template.assigns['foo'] = [model]
assert_template_result("{:test=>1234}", template.render!)
end
def test_map_on_hashes def test_map_on_hashes
assert_template_result("4217", '{{ thing | map: "foo" | map: "bar" }}', assert_template_result("4217", '{{ thing | map: "foo" | map: "bar" }}',
"thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] }) "thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] })
@@ -441,9 +467,9 @@ class StandardFiltersTest < Minitest::Test
end end
def test_map_over_proc def test_map_over_proc
drop = TestDrop.new drop = TestDrop.new(value: "testfoo")
p = proc { drop } p = proc { drop }
templ = '{{ procs | map: "test" }}' templ = '{{ procs | map: "value" }}'
assert_template_result("testfoo", templ, "procs" => [p]) assert_template_result("testfoo", templ, "procs" => [p])
end end
@@ -539,19 +565,31 @@ class StandardFiltersTest < Minitest::Test
end end
def test_replace def test_replace
assert_equal('2 2 2 2', @filters.replace('1 1 1 1', '1', 2)) assert_equal('b b b b', @filters.replace('a a a a', 'a', 'b'))
assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2)) assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2))
assert_equal('2 1 1 1', @filters.replace_first('1 1 1 1', '1', 2)) assert_equal('1 1 1 1', @filters.replace('1 1 1 1', 2, 3))
assert_template_result('2 2 2 2', "{{ '1 1 1 1' | replace: '1', 2 }}")
assert_equal('b a a a', @filters.replace_first('a a a a', 'a', 'b'))
assert_equal('2 1 1 1', @filters.replace_first('1 1 1 1', 1, 2)) assert_equal('2 1 1 1', @filters.replace_first('1 1 1 1', 1, 2))
assert_equal('1 1 1 1', @filters.replace_first('1 1 1 1', 2, 3))
assert_template_result('2 1 1 1', "{{ '1 1 1 1' | replace_first: '1', 2 }}") assert_template_result('2 1 1 1', "{{ '1 1 1 1' | replace_first: '1', 2 }}")
assert_equal('a a a b', @filters.replace_last('a a a a', 'a', 'b'))
assert_equal('1 1 1 2', @filters.replace_last('1 1 1 1', 1, 2))
assert_equal('1 1 1 1', @filters.replace_last('1 1 1 1', 2, 3))
assert_template_result('1 1 1 2', "{{ '1 1 1 1' | replace_last: '1', 2 }}")
end end
def test_remove def test_remove
assert_equal(' ', @filters.remove("a a a a", 'a')) assert_equal(' ', @filters.remove("a a a a", 'a'))
assert_equal(' ', @filters.remove("1 1 1 1", 1)) assert_template_result(' ', "{{ '1 1 1 1' | remove: 1 }}")
assert_equal('a a a', @filters.remove_first("a a a a", 'a '))
assert_equal(' 1 1 1', @filters.remove_first("1 1 1 1", 1)) assert_equal('b a a', @filters.remove_first("a b a a", 'a '))
assert_template_result('a a a', "{{ 'a a a a' | remove_first: 'a ' }}") assert_template_result(' 1 1 1', "{{ '1 1 1 1' | remove_first: 1 }}")
assert_equal('a a b', @filters.remove_last("a a b a", ' a'))
assert_template_result('1 1 1 ', "{{ '1 1 1 1' | remove_last: 1 }}")
end end
def test_pipes_in_string_arguments def test_pipes_in_string_arguments
@@ -827,7 +865,7 @@ class StandardFiltersTest < Minitest::Test
end end
def test_all_filters_never_raise_non_liquid_exception def test_all_filters_never_raise_non_liquid_exception
test_drop = TestDrop.new test_drop = TestDrop.new(value: "test")
test_drop.context = Context.new test_drop.context = Context.new
test_enum = TestEnumerable.new test_enum = TestEnumerable.new
test_enum.context = Context.new test_enum.context = Context.new
@@ -852,19 +890,14 @@ class StandardFiltersTest < Minitest::Test
{ 1 => "bar" }, { 1 => "bar" },
["foo", 123, nil, true, false, Drop, ["foo"], { foo: "bar" }], ["foo", 123, nil, true, false, Drop, ["foo"], { foo: "bar" }],
] ]
test_types.each do |first| StandardFilters.public_instance_methods(false).each do |method|
test_types.each do |other| arg_count = @filters.method(method).arity
(@filters.methods - Object.methods).each do |method| arg_count *= -1 if arg_count < 0
arg_count = @filters.method(method).arity
arg_count *= -1 if arg_count < 0 test_types.repeated_permutation(arg_count) do |args|
inputs = [first] @filters.send(method, *args)
inputs << ([other] * (arg_count - 1)) if arg_count > 1 rescue Liquid::Error
begin nil
@filters.send(method, *inputs)
rescue Liquid::ArgumentError, Liquid::ZeroDivisionError
nil
end
end
end end
end end
end end
+10 -10
View File
@@ -72,21 +72,21 @@ module Minitest
end end
def with_global_filter(*globals) def with_global_filter(*globals)
original_global_filters = Liquid::StrainerFactory.instance_variable_get(:@global_filters) original_global_cache = Liquid::StrainerFactory::GlobalCache
Liquid::StrainerFactory.instance_variable_set(:@global_filters, []) Liquid::StrainerFactory.send(:remove_const, :GlobalCache)
globals.each do |global| Liquid::StrainerFactory.const_set(:GlobalCache, Class.new(Liquid::StrainerTemplate))
Liquid::StrainerFactory.add_global_filter(global)
end
Liquid::StrainerFactory.send(:strainer_class_cache).clear
globals.each do |global| globals.each do |global|
Liquid::Template.register_filter(global) Liquid::Template.register_filter(global)
end end
yield
ensure
Liquid::StrainerFactory.send(:strainer_class_cache).clear Liquid::StrainerFactory.send(:strainer_class_cache).clear
Liquid::StrainerFactory.instance_variable_set(:@global_filters, original_global_filters) begin
yield
ensure
Liquid::StrainerFactory.send(:remove_const, :GlobalCache)
Liquid::StrainerFactory.const_set(:GlobalCache, original_global_cache)
Liquid::StrainerFactory.send(:strainer_class_cache).clear
end
end end
def with_error_mode(mode) def with_error_mode(mode)
+27 -14
View File
@@ -10,8 +10,8 @@ class ConditionUnitTest < Minitest::Test
end 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(Context.new))
assert_equal(true, Condition.new(1, '==', 1).evaluate) assert_equal(true, Condition.new(1, '==', 1).evaluate(Context.new))
end end
def test_default_operators_evalute_true def test_default_operators_evalute_true
@@ -67,11 +67,11 @@ class ConditionUnitTest < Minitest::Test
end end
def test_hash_compare_backwards_compatibility def test_hash_compare_backwards_compatibility
assert_nil(Condition.new({}, '>', 2).evaluate) assert_nil(Condition.new({}, '>', 2).evaluate(Context.new))
assert_nil(Condition.new(2, '>', {}).evaluate) assert_nil(Condition.new(2, '>', {}).evaluate(Context.new))
assert_equal(false, Condition.new({}, '==', 2).evaluate) assert_equal(false, Condition.new({}, '==', 2).evaluate(Context.new))
assert_equal(true, Condition.new({ 'a' => 1 }, '==', 'a' => 1).evaluate) assert_equal(true, Condition.new({ 'a' => 1 }, '==', 'a' => 1).evaluate(Context.new))
assert_equal(true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate) assert_equal(true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate(Context.new))
end end
def test_contains_works_on_arrays def test_contains_works_on_arrays
@@ -106,30 +106,29 @@ class ConditionUnitTest < Minitest::Test
def test_or_condition def test_or_condition
condition = Condition.new(1, '==', 2) condition = Condition.new(1, '==', 2)
assert_equal(false, condition.evaluate(Context.new))
assert_equal(false, condition.evaluate)
condition.or(Condition.new(2, '==', 1)) condition.or(Condition.new(2, '==', 1))
assert_equal(false, condition.evaluate) assert_equal(false, condition.evaluate(Context.new))
condition.or(Condition.new(1, '==', 1)) condition.or(Condition.new(1, '==', 1))
assert_equal(true, condition.evaluate) assert_equal(true, condition.evaluate(Context.new))
end end
def test_and_condition def test_and_condition
condition = Condition.new(1, '==', 1) condition = Condition.new(1, '==', 1)
assert_equal(true, condition.evaluate) assert_equal(true, condition.evaluate(Context.new))
condition.and(Condition.new(2, '==', 2)) condition.and(Condition.new(2, '==', 2))
assert_equal(true, condition.evaluate) assert_equal(true, condition.evaluate(Context.new))
condition.and(Condition.new(2, '==', 1)) condition.and(Condition.new(2, '==', 1))
assert_equal(false, condition.evaluate) assert_equal(false, condition.evaluate(Context.new))
end end
def test_should_allow_custom_proc_operator def test_should_allow_custom_proc_operator
@@ -148,6 +147,20 @@ class ConditionUnitTest < Minitest::Test
assert_evaluates_true(VariableLookup.new("one"), '==', VariableLookup.new("another")) assert_evaluates_true(VariableLookup.new("one"), '==', VariableLookup.new("another"))
end end
def test_default_context_is_deprecated
if Gem::Version.new(Liquid::VERSION) >= Gem::Version.new('6.0.0')
flunk("Condition#evaluate without a context argument is to be removed")
end
_out, err = capture_io do
assert_equal(true, Condition.new(1, '==', 1).evaluate)
end
expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
" and will be removed from Liquid 6.0.0."
assert_includes(err.lines.map(&:strip), expected)
end
private private
def assert_evaluates_true(left, op, right) def assert_evaluates_true(left, op, right)
+2 -1
View File
@@ -52,7 +52,8 @@ class StrainerFactoryUnitTest < Minitest::Test
/\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/,
exception.message exception.message
) )
assert_equal(exception.backtrace[0].split(':')[0], __FILE__) source = AccessScopeFilters.instance_method(:public_filter).source_location
assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1])
end end
def test_strainer_only_invokes_public_filter_methods def test_strainer_only_invokes_public_filter_methods
+1 -1
View File
@@ -57,8 +57,8 @@ class StrainerTemplateUnitTest < Minitest::Test
end end
def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method
strainer = Context.new.strainer
with_global_filter do with_global_filter do
strainer = Context.new.strainer
strainer.class.add_filter(PublicMethodOverrideFilter) strainer.class.add_filter(PublicMethodOverrideFilter)
assert(strainer.class.send(:filter_methods).include?('public_filter')) assert(strainer.class.send(:filter_methods).include?('public_filter'))
end end