Merge pull request #1091 from Shopify/rendering-with-less-garbage

Rendering with less garbage
This commit is contained in:
Florian Weingarten
2019-07-19 15:53:22 +01:00
committed by GitHub
29 changed files with 245 additions and 106 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ pkg
.ruby-version
Gemfile.lock
.bundle
.byebug_history

View File

@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2019-03-19 11:04:37 -0400 using RuboCop version 0.53.0.
# on 2019-04-22 19:11:24 -0400 using RuboCop version 0.53.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@@ -46,18 +46,18 @@ Lint/Void:
Exclude:
- 'lib/liquid/parse_context.rb'
# Offense count: 54
# Offense count: 53
Metrics/AbcSize:
Max: 56
# Offense count: 12
Metrics/CyclomaticComplexity:
Max: 12
Max: 13
# Offense count: 112
# Configuration parameters: CountComments.
Metrics/MethodLength:
Max: 37
Max: 38
# Offense count: 8
Metrics/PerceivedComplexity:
@@ -90,7 +90,7 @@ Naming/UncommunicativeMethodParamName:
- 'test/integration/template_test.rb'
- 'test/unit/condition_unit_test.rb'
# Offense count: 10
# Offense count: 12
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: prefer_alias, prefer_alias_method
@@ -253,7 +253,7 @@ Style/WhileUntilModifier:
Exclude:
- 'lib/liquid/tags/case.rb'
# Offense count: 640
# Offense count: 648
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
# URISchemes: http, https
Metrics/LineLength:

View File

@@ -13,6 +13,7 @@ module Liquid
end
end
# For backwards compatibility
def render(context)
@body.render(context)
end

View File

@@ -67,19 +67,23 @@ module Liquid
end
def render(context)
output = []
render_to_output_buffer(context, '')
end
def render_to_output_buffer(context, output)
context.resource_limits.render_score += @nodelist.length
idx = 0
while node = @nodelist[idx]
previous_output_size = output.bytesize
case node
when String
check_resources(context, node)
output << node
when Variable
render_node_to_output(node, output, context)
render_node(context, output, node)
when Block
render_node_to_output(node, output, context, node.blank?)
render_node(context, node.blank? ? '' : output, node)
break if context.interrupt? # might have happened in a for-block
when Continue, Break
# If we get an Interrupt that means the block must stop processing. An
@@ -88,34 +92,30 @@ module Liquid
context.push_interrupt(node.interrupt)
break
else # Other non-Block tags
render_node_to_output(node, output, context)
render_node(context, output, node)
break if context.interrupt? # might have happened through an include
end
idx += 1
raise_if_resource_limits_reached(context, output.bytesize - previous_output_size)
end
output.join
output
end
private
def render_node_to_output(node, output, context, skip_output = false)
node_output = node.render(context)
node_output = node_output.is_a?(Array) ? node_output.join : node_output.to_s
check_resources(context, node_output)
output << node_output unless skip_output
rescue MemoryError => e
raise e
def render_node(context, output, node)
node.render_to_output_buffer(context, output)
rescue UndefinedVariable, UndefinedDropMethod, UndefinedFilter => e
context.handle_error(e, node.line_number)
output << nil
rescue ::StandardError => e
line_number = node.is_a?(String) ? nil : node.line_number
output << context.handle_error(e, line_number)
end
def check_resources(context, node_output)
context.resource_limits.render_length += node_output.bytesize
def raise_if_resource_limits_reached(context, length)
context.resource_limits.render_length += length
return unless context.resource_limits.reached?
raise MemoryError.new("Memory limits exceeded".freeze)
end

View File

@@ -1,23 +1,23 @@
module Liquid
class BlockBody
def render_node_with_profiling(node, output, context, skip_output = false)
def render_node_with_profiling(context, output, node)
Profiler.profile_node_render(node) do
render_node_without_profiling(node, output, context, skip_output)
render_node_without_profiling(context, output, node)
end
end
alias_method :render_node_without_profiling, :render_node_to_output
alias_method :render_node_to_output, :render_node_with_profiling
alias_method :render_node_without_profiling, :render_node
alias_method :render_node, :render_node_with_profiling
end
class Include < Tag
def render_with_profiling(context)
def render_to_output_buffer_with_profiling(context, output)
Profiler.profile_children(context.evaluate(@template_name_expr).to_s) do
render_without_profiling(context)
render_to_output_buffer_without_profiling(context, output)
end
end
alias_method :render_without_profiling, :render
alias_method :render, :render_with_profiling
alias_method :render_to_output_buffer_without_profiling, :render_to_output_buffer
alias_method :render_to_output_buffer, :render_to_output_buffer_with_profiling
end
end

View File

@@ -36,6 +36,14 @@ module Liquid
''.freeze
end
# For backwards compatibility with custom tags. In a future release, the semantics
# of the `render_to_output_buffer` method will become the default and the `render`
# method will be removed.
def render_to_output_buffer(context, output)
output << render(context)
output
end
def blank?
false
end

View File

@@ -22,11 +22,11 @@ module Liquid
end
end
def render(context)
def render_to_output_buffer(context, output)
val = @from.render(context)
context.scopes.last[@to] = val
context.resource_limits.assign_score += assign_score_of(val)
''.freeze
output
end
def blank?

View File

@@ -22,11 +22,12 @@ module Liquid
end
end
def render(context)
output = super
def render_to_output_buffer(context, output)
previous_output_size = output.bytesize
super
context.scopes.last[@to] = output
context.resource_limits.assign_score += output.bytesize
''.freeze
context.resource_limits.assign_score += (output.bytesize - previous_output_size)
output
end
def blank?

View File

@@ -38,21 +38,21 @@ module Liquid
end
end
def render(context)
def render_to_output_buffer(context, output)
context.stack do
execute_else_block = true
output = ''
@blocks.each do |block|
if block.else?
return block.attachment.render(context) if execute_else_block
block.attachment.render_to_output_buffer(context, output) if execute_else_block
elsif block.evaluate(context)
execute_else_block = false
output << block.attachment.render(context)
block.attachment.render_to_output_buffer(context, output)
end
end
output
end
output
end
private

View File

@@ -1,7 +1,7 @@
module Liquid
class Comment < Block
def render(_context)
''.freeze
def render_to_output_buffer(_context, output)
output
end
def unknown_tag(_tag, _markup, _tokens)

View File

@@ -31,18 +31,29 @@ module Liquid
end
end
def render(context)
def render_to_output_buffer(context, output)
context.registers[:cycle] ||= {}
context.stack do
key = context.evaluate(@name)
iteration = context.registers[:cycle][key].to_i
result = context.evaluate(@variables[iteration])
val = context.evaluate(@variables[iteration])
if val.is_a?(Array)
val = val.join
elsif !val.is_a?(String)
val = val.to_s
end
output << val
iteration += 1
iteration = 0 if iteration >= @variables.size
context.registers[:cycle][key] = iteration
result
end
output
end
private

View File

@@ -23,11 +23,12 @@ module Liquid
@variable = markup.strip
end
def render(context)
def render_to_output_buffer(context, output)
value = context.environments.first[@variable] ||= 0
value -= 1
context.environments.first[@variable] = value
value.to_s
output << value.to_s
output
end
end

View File

@@ -70,14 +70,16 @@ module Liquid
@else_block = BlockBody.new
end
def render(context)
def render_to_output_buffer(context, output)
segment = collection_segment(context)
if segment.empty?
render_else(context)
render_else(context, output)
else
render_segment(context, segment)
render_segment(context, output, segment)
end
output
end
protected
@@ -150,12 +152,10 @@ module Liquid
segment
end
def render_segment(context, segment)
def render_segment(context, output, segment)
for_stack = context.registers[:for_stack] ||= []
length = segment.length
result = ''
context.stack do
loop_vars = Liquid::ForloopDrop.new(@name, length, for_stack[-1])
@@ -166,7 +166,7 @@ module Liquid
segment.each do |item|
context[@variable_name] = item
result << @for_block.render(context)
@for_block.render_to_output_buffer(context, output)
loop_vars.send(:increment!)
# Handle any interrupts if they exist.
@@ -181,7 +181,7 @@ module Liquid
end
end
result
output
end
def set_attribute(key, expr)
@@ -197,8 +197,12 @@ module Liquid
end
end
def render_else(context)
@else_block ? @else_block.render(context) : ''.freeze
def render_else(context, output)
if @else_block
@else_block.render_to_output_buffer(context, output)
else
output
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor

View File

@@ -39,15 +39,16 @@ module Liquid
end
end
def render(context)
def render_to_output_buffer(context, output)
context.stack do
@blocks.each do |block|
if block.evaluate(context)
return block.attachment.render(context)
return block.attachment.render_to_output_buffer(context, output)
end
end
''.freeze
end
output
end
private

View File

@@ -1,16 +1,17 @@
module Liquid
class Ifchanged < Block
def render(context)
def render_to_output_buffer(context, output)
context.stack do
output = super
block_output = ''
super(context, block_output)
if output != context.registers[:ifchanged]
context.registers[:ifchanged] = output
output
else
''.freeze
if block_output != context.registers[:ifchanged]
context.registers[:ifchanged] = block_output
output << block_output
end
end
output
end
end

View File

@@ -42,7 +42,7 @@ module Liquid
def parse(_tokens)
end
def render(context)
def render_to_output_buffer(context, output)
template_name = context.evaluate(@template_name_expr)
raise ArgumentError.new(options[:locale].t("errors.argument.include")) unless template_name
@@ -66,19 +66,21 @@ module Liquid
end
if variable.is_a?(Array)
variable.collect do |var|
variable.each do |var|
context[context_variable_name] = var
partial.render(context)
partial.render_to_output_buffer(context, output)
end
else
context[context_variable_name] = variable
partial.render(context)
partial.render_to_output_buffer(context, output)
end
end
ensure
context.template_name = old_template_name
context.partial = old_partial
end
output
end
private

View File

@@ -20,10 +20,11 @@ module Liquid
@variable = markup.strip
end
def render(context)
def render_to_output_buffer(context, output)
value = context.environments.first[@variable] ||= 0
context.environments.first[@variable] = value + 1
value.to_s
output << value.to_s
output
end
end

View File

@@ -22,8 +22,9 @@ module Liquid
raise SyntaxError.new(parse_context.locale.t("errors.syntax.tag_never_closed".freeze, block_name: block_name))
end
def render(_context)
@body
def render_to_output_buffer(_context, output)
output << @body
output
end
def nodelist

View File

@@ -18,7 +18,7 @@ module Liquid
end
end
def render(context)
def render_to_output_buffer(context, output)
collection = context.evaluate(@collection_name) or return ''.freeze
from = @attributes.key?('offset'.freeze) ? context.evaluate(@attributes['offset'.freeze]).to_i : 0
@@ -30,7 +30,7 @@ module Liquid
cols = context.evaluate(@attributes['cols'.freeze]).to_i
result = "<tr class=\"row1\">\n"
output << "<tr class=\"row1\">\n"
context.stack do
tablerowloop = Liquid::TablerowloopDrop.new(length, cols)
context['tablerowloop'.freeze] = tablerowloop
@@ -38,17 +38,20 @@ module Liquid
collection.each do |item|
context[@variable_name] = item
result << "<td class=\"col#{tablerowloop.col}\">" << super << '</td>'
output << "<td class=\"col#{tablerowloop.col}\">"
super
output << '</td>'
if tablerowloop.col_last && !tablerowloop.last
result << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
end
tablerowloop.send(:increment!)
end
end
result << "</tr>\n"
result
output << "</tr>\n"
output
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor

View File

@@ -6,23 +6,23 @@ module Liquid
# {% unless x < 0 %} x is greater than zero {% endunless %}
#
class Unless < If
def render(context)
def render_to_output_buffer(context, output)
context.stack do
# First condition is interpreted backwards ( if not )
first_block = @blocks.first
unless first_block.evaluate(context)
return first_block.attachment.render(context)
return first_block.attachment.render_to_output_buffer(context, output)
end
# After the first condition unless works just like if
@blocks[1..-1].each do |block|
if block.evaluate(context)
return block.attachment.render(context)
return block.attachment.render_to_output_buffer(context, output)
end
end
''.freeze
end
output
end
end

View File

@@ -187,9 +187,12 @@ module Liquid
raise ArgumentError, "Expected Hash or Liquid::Context as parameter"
end
output = nil
case args.last
when Hash
options = args.pop
output = options[:output] if options[:output]
registers.merge!(options[:registers]) if options[:registers].is_a?(Hash)
@@ -204,10 +207,9 @@ module Liquid
begin
# render the nodelist.
# for performance reasons we get an array back here. join will make a string out of it.
result = with_profiling(context) do
@root.render(context)
with_profiling(context) do
@root.render_to_output_buffer(context, output || '')
end
result.respond_to?(:join) ? result.join : result
rescue Liquid::MemoryError => e
context.handle_error(e)
ensure
@@ -220,6 +222,10 @@ module Liquid
render(*args)
end
def render_to_output_buffer(context, output)
render(context, output: output)
end
private
def tokenize(source)

View File

@@ -85,12 +85,23 @@ module Liquid
end
obj = context.apply_global_filter(obj)
taint_check(context, obj)
obj
end
def render_to_output_buffer(context, output)
obj = render(context)
if obj.is_a?(Array)
output << obj.join
elsif obj.nil?
else
output << obj.to_s
end
output
end
private
def parse_filter_expressions(filter_name, unparsed_args)

View File

@@ -12,7 +12,7 @@ class CommentForm < Liquid::Block
end
end
def render(context)
def render_to_output_buffer(context, output)
article = context[@variable_name]
context.stack do
@@ -23,7 +23,9 @@ class CommentForm < Liquid::Block
'email' => context['comment.email'],
'body' => context['comment.body']
}
wrap_in_form(article, render_all(@nodelist, context))
output << wrap_in_form(article, render_all(@nodelist, context, output))
output
end
end

View File

@@ -21,7 +21,7 @@ class Paginate < Liquid::Block
end
end
def render(context)
def render_to_output_buffer(context, output)
@context = context
context.stack do

View File

@@ -1,11 +1,10 @@
require 'test_helper'
class FoobarTag < Liquid::Tag
def render(*args)
" "
def render_to_output_buffer(context, output)
output << ' '
output
end
Liquid::Template.register_tag('foobar', FoobarTag)
end
class BlankTestFileSystem
@@ -31,7 +30,9 @@ class BlankTest < Minitest::Test
end
def test_new_tags_are_not_blank_by_default
assert_template_result(" " * N, wrap_in_for("{% foobar %}"))
with_custom_tag('foobar', FoobarTag) do
assert_template_result(" " * N, wrap_in_for("{% foobar %}"))
end
end
def test_loops_are_blank

View File

@@ -66,8 +66,9 @@ class CustomInclude < Liquid::Tag
def parse(tokens)
end
def render(context)
@template_name[1..-2]
def render_to_output_buffer(context, output)
output << @template_name[1..-2]
output
end
end

View File

@@ -84,6 +84,13 @@ module Minitest
ensure
Liquid::Template.error_mode = old_mode
end
def with_custom_tag(tag_name, tag_class)
Liquid::Template.register_tag(tag_name, tag_class)
yield
ensure
Liquid::Template.tags.delete(tag_name)
end
end
end

View File

@@ -44,10 +44,47 @@ class BlockUnitTest < Minitest::Test
end
def test_with_custom_tag
Liquid::Template.register_tag("testtag", Block)
assert Liquid::Template.parse("{% testtag %} {% endtesttag %}")
ensure
Liquid::Template.tags.delete('testtag')
with_custom_tag('testtag', Block) do
assert Liquid::Template.parse("{% testtag %} {% endtesttag %}")
end
end
def test_custom_block_tags_have_a_default_render_to_output_buffer_method_for_backwards_compatibility
klass1 = Class.new(Block) do
def render(*)
'hello'
end
end
with_custom_tag('blabla', klass1) do
template = Liquid::Template.parse("{% blabla %} bla {% endblabla %}")
assert_equal 'hello', template.render
buf = ''
output = template.render({}, output: buf)
assert_equal 'hello', output
assert_equal 'hello', buf
assert_equal buf.object_id, output.object_id
end
klass2 = Class.new(klass1) do
def render(*)
'foo' + super + 'bar'
end
end
with_custom_tag('blabla', klass2) do
template = Liquid::Template.parse("{% blabla %} foo {% endblabla %}")
assert_equal 'foohellobar', template.render
buf = ''
output = template.render({}, output: buf)
assert_equal 'foohellobar', output
assert_equal 'foohellobar', buf
assert_equal buf.object_id, output.object_id
end
end
private

View File

@@ -18,4 +18,42 @@ class TagUnitTest < Minitest::Test
tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
assert_equal 'some_tag', tag.tag_name
end
def test_custom_tags_have_a_default_render_to_output_buffer_method_for_backwards_compatibility
klass1 = Class.new(Tag) do
def render(*)
'hello'
end
end
with_custom_tag('blabla', klass1) do
template = Liquid::Template.parse("{% blabla %}")
assert_equal 'hello', template.render
buf = ''
output = template.render({}, output: buf)
assert_equal 'hello', output
assert_equal 'hello', buf
assert_equal buf.object_id, output.object_id
end
klass2 = Class.new(klass1) do
def render(*)
'foo' + super + 'bar'
end
end
with_custom_tag('blabla', klass2) do
template = Liquid::Template.parse("{% blabla %}")
assert_equal 'foohellobar', template.render
buf = ''
output = template.render({}, output: buf)
assert_equal 'foohellobar', output
assert_equal 'foohellobar', buf
assert_equal buf.object_id, output.object_id
end
end
end