Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ group :test do
gem 'rubocop', '~> 0.53.0'

platform :mri do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: '9168659de45d6d576fce30c735f857e597fa26f6'
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'reversable-range'
end
end
1 change: 1 addition & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ module Liquid
require 'liquid/utils'
require 'liquid/tokenizer'
require 'liquid/parse_context'
require 'liquid/reversable_range'

# Load all the tags of the standard library
#
Expand Down
4 changes: 2 additions & 2 deletions lib/liquid/range_lookup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def self.parse(start_markup, end_markup)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
start_obj.to_i..end_obj.to_i
ReversableRange.new(start_obj.to_i, end_obj.to_i)
end
end

Expand All @@ -18,7 +18,7 @@ def initialize(start_obj, end_obj)
def evaluate(context)
start_int = to_integer(context.evaluate(@start_obj))
end_int = to_integer(context.evaluate(@end_obj))
start_int..end_int
ReversableRange.new(start_int, end_int)
end

private
Expand Down
77 changes: 77 additions & 0 deletions lib/liquid/reversable_range.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
module Liquid
class ReversableRange
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reversible is a more correct spelling I think.

include Enumerable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've been moving away from using Ruby builtin modules in Liquid-accessible types, as their public interface can change between Ruby versions. However, it looks okay to use Enumerable here as this is not a drop and doesn't expose its methods to Liquid.


def initialize(min, max)
@min = min
@max = max
@reversed = false
end

def each
if reversed
index = max
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's likely a premature optimization, but using ivars directly is a fair bit faster in general.

Warming up --------------------------------------
         attr_reader   147.241k i/s -    150.359k times in 1.021174s (6.79μs/i)
                ivar   247.108k i/s -    249.557k times in 1.009912s (4.05μs/i)
Calculating -------------------------------------
         attr_reader   143.032k i/s -    441.723k times in 3.088285s (6.99μs/i)
                ivar   255.390k i/s -    741.323k times in 2.902711s (3.92μs/i)

Comparison:
                ivar:    255389.9 i/s
         attr_reader:    143031.8 i/s - 1.79x  slower

with

    def each
      if reversed
        index = @max
        while index >= @min
          yield index
          index -= 1
        end
      else
        index = @min
        while index <= @max
          yield index
          index += 1
        end
      end
    end

using ReversableRange.new(1, 100)

while index >= min
yield index
index -= 1
end
else
index = min
while index <= max
yield index
index += 1
end
end
end

def reverse!
@reversed = !reversed
self
end

def empty?
max < min
end

def size
difference = max - min
if difference > 0
difference + 1
else
0
end
end

def load_slice(from, to = nil)
to ||= max
slice_max = [max, to].min
slice_min = [min, from].max
range = ReversableRange.new(slice_min, slice_max)
range.reverse! if reversed
range
end

def ==(other)
other.is_a?(self.class) &&
other.min == min &&
other.max == max &&
other.reversed == reversed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we care about making ranges with switched min/max and opposite reverse being equal?

end

def to_s
if reversed
"#{max}..#{min}"
else
"#{min}..#{max}"
end
end

def to_liquid
self
end

protected

attr_reader :min, :max, :reversed
end
end
5 changes: 2 additions & 3 deletions lib/liquid/tags/for.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ def collection_segment(context)
end

collection = context.evaluate(@collection_name)
collection = collection.to_a if collection.is_a?(Range)

limit_value = context.evaluate(@limit)
to = if limit_value.nil?
Expand All @@ -145,14 +144,14 @@ def collection_segment(context)
segment = Utils.slice_collection(collection, from, to)
segment.reverse! if @reversed
Copy link
Contributor

@pushrax pushrax Jun 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Range doesn't have reverse! (or reverse), could you add a test for that? To make this work, I think we may need our own range class, since reverse_each uses a temporary array. Or just convert to an array in the reverse case and keep status quo, but I think the optimization here is great and not too hard.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

range.max.downto(range.min) should also work there, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, actually that would make empty? checking difficult. I'll throw together a custom enumerator.


offsets[@name] = from + segment.length
offsets[@name] = from + segment.size

segment
end

def render_segment(context, segment)
for_stack = context.registers[:for_stack] ||= []
length = segment.length
length = segment.size

result = ''

Expand Down
2 changes: 2 additions & 0 deletions lib/liquid/utils.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module Liquid
module Utils
def self.slice_collection(collection, from, to)
return collection.load_slice(from, to) if collection.is_a?(ReversableRange)

if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
collection.load_slice(from, to)
else
Expand Down
4 changes: 4 additions & 0 deletions test/integration/tags/for_tag_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def test_for_reversed
assert_template_result('321', '{%for item in array reversed %}{{item}}{%endfor%}', assigns)
end

def test_for_range_reversed
assert_template_result('321', '{%for item in (1..3) reversed %}{{item}}{%endfor%}', {})
end

def test_for_with_range
assert_template_result(' 1 2 3 ', '{%for item in (1..3) %} {{item}} {%endfor%}')

Expand Down
6 changes: 3 additions & 3 deletions test/unit/context_unit_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,9 @@ def test_nested_context_from_within_drop

def test_ranges
@context.merge("test" => '5')
assert_equal (1..5), @context['(1..5)']
assert_equal (1..5), @context['(1..test)']
assert_equal (5..5), @context['(test..test)']
assert_equal ReversableRange.new(1, 5), @context['(1..5)']
assert_equal ReversableRange.new(1, 5), @context['(1..test)']
assert_equal ReversableRange.new(5, 5), @context['(test..test)']
end

def test_cents_through_drop_nestedly
Expand Down
116 changes: 116 additions & 0 deletions test/unit/reversable_range_unit_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
require 'test_helper'

class ReversableRangeTest < Minitest::Test
include Liquid

def test_each_iterates_through_items_in_the_range
actual_items = []
ReversableRange.new(1, 10).each { |item| actual_items << item }

expected_items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert_equal expected_items, actual_items
end

def test_implements_enumerable
actual_items = ReversableRange.new(1, 10).select(&:even?)

expected_items = [2, 4, 6, 8, 10]
assert_equal expected_items, actual_items
end

def test_is_not_empty_max_greater_than_min
range = ReversableRange.new(9, 10)

refute_predicate range, :empty?
end

def test_is_not_empty_max_equal_to_min
range = ReversableRange.new(10, 10)

refute_predicate range, :empty?
end

def test_is_empty_if_not_reversed_and_max_less_than_min
range = ReversableRange.new(10, 9)

assert_predicate range, :empty?
end

def test_ranges_with_the_same_max_and_min_have_one_element
actual_items = ReversableRange.new(1337, 1337).to_a
expected_items = [1337]
assert_equal expected_items, actual_items
end

def test_load_slice_returns_a_sub_range
actual_items = ReversableRange.new(1, 10).load_slice(5, 8).to_a

expected_items = [5, 6, 7, 8]
assert_equal expected_items, actual_items
end

def test_load_slice_returns_a_reversed_sub_range_if_reversed
range = ReversableRange.new(1, 10)
range.reverse!
actual_items = range.load_slice(5, 8).to_a

expected_items = [8, 7, 6, 5]
assert_equal expected_items, actual_items
end

def test_is_equal_to_other_if_also_a_reversable_range_and_has_same_properties
one = ReversableRange.new(1, 10)
one.reverse!

two = ReversableRange.new(1, 10)
two.reverse!

assert_equal one, two
end

def test_is_not_equal_to_a_non_reversable_range
range = ReversableRange.new(1, 10)
range.reverse!

refute_equal range, :something_else
end

def test_is_not_equal_if_ranges_have_different_mins
one = ReversableRange.new(1, 10)
two = ReversableRange.new(2, 10)

refute_equal one, two
end

def test_is_not_equal_if_ranges_have_different_maxes
one = ReversableRange.new(1, 10)
two = ReversableRange.new(1, 11)

refute_equal one, two
end

def test_is_not_equal_if_only_one_is_reversed
one = ReversableRange.new(1, 10)

two = ReversableRange.new(1, 10)
two.reverse!

refute_equal one, two
end

def test_to_s_mirrors_rubys_range_syntax
range = ReversableRange.new(1, 10)
assert_equal '1..10', range.to_s
end

def test_to_s_reverses_when_reversed
range = ReversableRange.new(1, 10)
range.reverse!
assert_equal '10..1', range.to_s
end

def test_size
range = ReversableRange.new(1, 10)
assert_equal 10, range.size
end
end