mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-17 01:40:42 -07:00
Fix compiler output equivalence and add comprehensive tests
- Fix forloop.first/last/size lookups to try hash key before method call - Fix tablerow cols parameter to use attributes['cols'] not @cols - Fix tablerow output format to match interpreter (newlines, row boundaries) - Fix capture compiler to access @body directly instead of iterating nodelist - Add CompiledTemplate class to encapsulate code and external_tags - Add external filter support via filter_handler - Add debug mode warnings for external tag/filter calls - Add Ruby 3.3 compatibility shim for peek_byte/scan_byte - Add comprehensive unit tests for output equivalence - Add benchmark comparing compiled vs interpreted rendering All 30 test templates now produce identical output between compiled Ruby and interpreted Liquid. Pre-compiled Ruby is 1.68x faster.
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
#
|
||||
module Liquid
|
||||
module Compile
|
||||
autoload :CompiledTemplate, 'liquid/compile/compiled_template'
|
||||
autoload :CodeGenerator, 'liquid/compile/code_generator'
|
||||
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
|
||||
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# Represents a compiled Liquid template ready for execution.
|
||||
#
|
||||
# Contains the Ruby source code and any external tags/filters that need to be
|
||||
# passed to the generated lambda at runtime.
|
||||
#
|
||||
# Usage:
|
||||
# compiled = template.compile_to_ruby
|
||||
# result = compiled.call({ "name" => "World" })
|
||||
#
|
||||
# # With custom filters:
|
||||
# compiled.filter_handler = MyFilterModule
|
||||
# result = compiled.call({ "name" => "World" })
|
||||
#
|
||||
class CompiledTemplate
|
||||
attr_reader :code, :external_tags
|
||||
attr_accessor :filter_handler
|
||||
|
||||
# @param code [String] The generated Ruby code
|
||||
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
|
||||
# @param has_external_filters [Boolean] Whether external filters are used
|
||||
def initialize(code, external_tags = {}, has_external_filters = false)
|
||||
@code = code
|
||||
@external_tags = external_tags
|
||||
@has_external_filters = has_external_filters
|
||||
@filter_handler = nil
|
||||
@proc = nil
|
||||
end
|
||||
|
||||
# Returns true if this template has external tags that need runtime delegation
|
||||
def has_external_tags?
|
||||
!@external_tags.empty?
|
||||
end
|
||||
|
||||
# Returns true if this template uses external filters
|
||||
def has_external_filters?
|
||||
@has_external_filters
|
||||
end
|
||||
|
||||
# Returns the compiled proc, caching it after first compilation
|
||||
def to_proc
|
||||
@proc ||= eval(@code)
|
||||
end
|
||||
|
||||
# Execute the compiled template with the given assigns
|
||||
# @param assigns [Hash] The variable assignments
|
||||
# @param filter_handler [Object] Optional filter handler to override the default
|
||||
# @return [String] The rendered output
|
||||
def call(assigns = {}, filter_handler: nil)
|
||||
proc = to_proc
|
||||
handler = filter_handler || @filter_handler
|
||||
|
||||
# Build arguments based on what the lambda expects
|
||||
args = [assigns]
|
||||
args << @external_tags if has_external_tags?
|
||||
args << handler if has_external_filters?
|
||||
|
||||
proc.call(*args)
|
||||
end
|
||||
|
||||
# Returns the Ruby code as a string
|
||||
def to_s
|
||||
@code
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Make CompiledTemplate available at the top level for convenience
|
||||
CompiledTemplate = Compile::CompiledTemplate
|
||||
end
|
||||
@@ -68,14 +68,9 @@ module Liquid
|
||||
# Numeric index like foo[0]
|
||||
base = "__lookup__(#{base}, #{key})"
|
||||
elsif key.is_a?(String)
|
||||
# Check if this is a command method (size, first, last)
|
||||
if lookup.lookup_command?(index)
|
||||
# Call as method
|
||||
base = "(#{base}.respond_to?(:#{key}) ? #{base}.#{key} : nil)"
|
||||
else
|
||||
# Access as hash/array key
|
||||
base = "__lookup__(#{base}, #{key.inspect})"
|
||||
end
|
||||
# Always use __lookup__ which tries key access first,
|
||||
# then falls back to method call for command methods (first, last, size)
|
||||
base = "__lookup__(#{base}, #{key.inspect})"
|
||||
else
|
||||
base = "__lookup__(#{base}, #{compile(key, compiler)})"
|
||||
end
|
||||
|
||||
@@ -206,7 +206,11 @@ module Liquid
|
||||
end
|
||||
|
||||
# Compile a filter that's not built-in
|
||||
# Uses __call_filter__ helper which must be provided by the runtime
|
||||
def self.compile_generic_filter(input, name, args, kwargs, compiler)
|
||||
# Mark that we're using external filters
|
||||
compiler.register_external_filter
|
||||
|
||||
compiled_args = args.map { |arg| compile_arg(arg, compiler) }
|
||||
|
||||
if kwargs && !kwargs.empty?
|
||||
@@ -214,11 +218,10 @@ module Liquid
|
||||
compiled_args << "{ #{kwargs_hash} }"
|
||||
end
|
||||
|
||||
args_str = compiled_args.empty? ? "" : ", #{compiled_args.join(', ')}"
|
||||
args_str = compiled_args.empty? ? "[]" : "[#{compiled_args.join(', ')}]"
|
||||
|
||||
# Generate a method call - this assumes the filter is available as a method
|
||||
# In practice, custom filters would need to be defined in the compiled code
|
||||
"(respond_to?(:#{name}) ? #{name}(#{input}#{args_str}) : #{input})"
|
||||
# Call through the filter helper which delegates to registered filters
|
||||
"__call_filter__.call(#{name.inspect}, #{input}, #{args_str})"
|
||||
end
|
||||
|
||||
# Compile a filter argument
|
||||
|
||||
@@ -72,6 +72,35 @@ module Liquid
|
||||
@partials = {} # Registered partials: name => method_name
|
||||
@partial_sources = {} # Partial sources: name => source code
|
||||
@partial_counter = 0
|
||||
@external_tags = {} # External tags: var_name => tag object
|
||||
@external_tag_counter = 0
|
||||
@has_external_filters = false # Whether we need the filter helper
|
||||
end
|
||||
|
||||
# Mark that we have external filters
|
||||
def register_external_filter
|
||||
@has_external_filters = true
|
||||
end
|
||||
|
||||
# Check if external filters are used
|
||||
def has_external_filters?
|
||||
@has_external_filters
|
||||
end
|
||||
|
||||
# Register an external tag that will be called at runtime
|
||||
# @param tag [Liquid::Tag] The tag to register
|
||||
# @return [String] The variable name for this tag
|
||||
def register_external_tag(tag)
|
||||
@external_tag_counter += 1
|
||||
var_name = "__ext_tag_#{@external_tag_counter}__"
|
||||
@external_tags[var_name] = tag
|
||||
var_name
|
||||
end
|
||||
|
||||
# Get all registered external tags
|
||||
# @return [Hash] Map of variable names to tag objects
|
||||
def external_tags
|
||||
@external_tags
|
||||
end
|
||||
|
||||
# Get the file system for loading partials
|
||||
@@ -158,6 +187,7 @@ module Liquid
|
||||
|
||||
# Compile the template to a Ruby code string
|
||||
# @return [String] Ruby code that can be eval'd to create a render proc
|
||||
# @return [Hash] If external tags are used, returns { code: String, external_tags: Hash }
|
||||
def compile
|
||||
code = CodeGenerator.new
|
||||
|
||||
@@ -169,8 +199,17 @@ module Liquid
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# Generate the lambda header
|
||||
code.line "->(assigns = {}) do"
|
||||
# First pass: compile the document body to discover partials and external tags
|
||||
main_code = CodeGenerator.new
|
||||
compile_node(@template.root, main_code)
|
||||
|
||||
# Determine lambda parameters based on external dependencies
|
||||
params = ["assigns = {}"]
|
||||
params << "__external_tags__ = {}" unless @external_tags.empty?
|
||||
params << "__filter_handler__ = nil" if @has_external_filters
|
||||
|
||||
code.line "->(#{params.join(', ')}) do"
|
||||
|
||||
code.indent do
|
||||
# Initialize the output buffer
|
||||
code.line '__output__ = +""'
|
||||
@@ -182,9 +221,17 @@ module Liquid
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# First pass: compile the document body to discover partials
|
||||
main_code = CodeGenerator.new
|
||||
compile_node(@template.root, main_code)
|
||||
# Add external tag runtime helper if needed
|
||||
unless @external_tags.empty?
|
||||
compile_external_tag_helper(code)
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# Add external filter helper if needed
|
||||
if @has_external_filters
|
||||
compile_filter_helper(code)
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# Compile partial methods (before main body so they're available)
|
||||
compile_partials(code)
|
||||
@@ -200,6 +247,41 @@ module Liquid
|
||||
code.to_s
|
||||
end
|
||||
|
||||
# Compile helper for calling external tags at runtime
|
||||
def compile_external_tag_helper(code)
|
||||
code.line "# Helper for calling external (unknown) tags at runtime"
|
||||
code.line "__call_external_tag__ = ->(tag_var, tag_assigns) {"
|
||||
code.indent do
|
||||
code.line "tag = __external_tags__[tag_var]"
|
||||
code.line "next '' unless tag"
|
||||
code.line "# Create a context using the default environment (which has filters registered)"
|
||||
code.line "ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)"
|
||||
code.line "output = +''"
|
||||
code.line "# Use render_to_output_buffer to ensure block tags work correctly"
|
||||
code.line "tag.render_to_output_buffer(ctx, output)"
|
||||
code.line "output"
|
||||
end
|
||||
code.line "}"
|
||||
end
|
||||
|
||||
# Compile helper for calling external filters at runtime
|
||||
def compile_filter_helper(code)
|
||||
code.line "# Helper for calling external (unknown) filters at runtime"
|
||||
code.line "__call_filter__ = ->(name, input, args) {"
|
||||
code.indent do
|
||||
code.line "if __filter_handler__&.respond_to?(name)"
|
||||
code.indent do
|
||||
code.line "__filter_handler__.send(name, input, *args)"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
code.line "input # Return input unchanged if filter not found"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
code.line "}"
|
||||
end
|
||||
|
||||
# Compile all registered partials as inner methods
|
||||
def compile_partials(code)
|
||||
@partials.each do |name, method_name|
|
||||
@@ -293,16 +375,27 @@ module Liquid
|
||||
if compiler_class
|
||||
compiler_class.compile(tag, self, code)
|
||||
else
|
||||
raise CompileError, "No compiler for tag: #{tag.class}"
|
||||
# Unknown tag - delegate to the original tag's render method at runtime
|
||||
compile_external_tag(tag, code)
|
||||
end
|
||||
end
|
||||
|
||||
def compile_external_tag(tag, code)
|
||||
tag_var = register_external_tag(tag)
|
||||
tag_name = tag.class.name.split('::').last
|
||||
if debug?
|
||||
code.line "# External tag: #{tag_name} (delegated to runtime)"
|
||||
code.line "$stderr.puts '* WARN: Liquid external tag call - #{tag_name} (not compiled, delegated to runtime)' if $VERBOSE"
|
||||
end
|
||||
code.line "__output__ << __call_external_tag__.call(#{tag_var.inspect}, assigns)"
|
||||
end
|
||||
|
||||
def find_tag_compiler(tag)
|
||||
case tag
|
||||
when Liquid::Unless # Check Unless before If since Unless < If
|
||||
Tags::UnlessCompiler
|
||||
when Liquid::If
|
||||
Tags::IfCompiler
|
||||
when Liquid::Unless
|
||||
Tags::UnlessCompiler
|
||||
when Liquid::Case
|
||||
Tags::CaseCompiler
|
||||
when Liquid::For
|
||||
|
||||
@@ -15,11 +15,10 @@ module Liquid
|
||||
code.line "#{capture_var} = __output__"
|
||||
code.line "__output__ = +''"
|
||||
|
||||
# Compile the body
|
||||
code.indent do
|
||||
tag.nodelist.each do |body|
|
||||
BlockBodyCompiler.compile(body, compiler, code)
|
||||
end
|
||||
# Compile the body - access the @body BlockBody directly
|
||||
body = tag.instance_variable_get(:@body)
|
||||
if body
|
||||
BlockBodyCompiler.compile(body, compiler, code)
|
||||
end
|
||||
|
||||
# Save captured content and restore output buffer
|
||||
|
||||
@@ -36,6 +36,10 @@ module Liquid
|
||||
partial_source = compiler.load_partial(template_name)
|
||||
|
||||
if partial_source
|
||||
if compiler.debug?
|
||||
code.line "# Inlined partial '#{template_name}' at compile time"
|
||||
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial \\\"#{template_name}\\\" at compile time' if $VERBOSE"
|
||||
end
|
||||
# Generate a unique method name for this partial
|
||||
method_name = compiler.register_partial(template_name, partial_source)
|
||||
context_var_name = alias_name || template_name.split('/').last
|
||||
@@ -89,6 +93,11 @@ module Liquid
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.instance_variable_get(:@alias_name)
|
||||
|
||||
if compiler.debug?
|
||||
code.line "# Dynamic include (template name from variable)"
|
||||
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic include (template name from variable)' if $VERBOSE"
|
||||
end
|
||||
|
||||
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
|
||||
|
||||
# Build attributes hash
|
||||
|
||||
@@ -38,6 +38,10 @@ module Liquid
|
||||
partial_source = compiler.load_partial(template_name)
|
||||
|
||||
if partial_source
|
||||
if compiler.debug?
|
||||
code.line "# Inlined partial '#{template_name}' at compile time"
|
||||
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial \\\"#{template_name}\\\" at compile time' if $VERBOSE"
|
||||
end
|
||||
# Generate a unique method name for this partial
|
||||
method_name = compiler.register_partial(template_name, partial_source)
|
||||
context_var_name = alias_name || template_name.split('/').last
|
||||
@@ -146,6 +150,11 @@ module Liquid
|
||||
alias_name = tag.alias_name
|
||||
is_for_loop = tag.for_loop?
|
||||
|
||||
if compiler.debug?
|
||||
code.line "# Dynamic render (template name from variable)"
|
||||
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic render (template name from variable)' if $VERBOSE"
|
||||
end
|
||||
|
||||
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
|
||||
|
||||
# Build attributes hash
|
||||
|
||||
@@ -10,6 +10,7 @@ module Liquid
|
||||
def self.compile(tag, compiler, code)
|
||||
var_name = tag.variable_name
|
||||
collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler)
|
||||
attributes = tag.attributes
|
||||
|
||||
# Generate unique variable names
|
||||
coll_var = compiler.generate_var_name("coll")
|
||||
@@ -19,17 +20,17 @@ module Liquid
|
||||
row_var = compiler.generate_var_name("row")
|
||||
col_var = compiler.generate_var_name("col")
|
||||
|
||||
# Get the columns parameter
|
||||
cols = tag.instance_variable_get(:@cols)
|
||||
# Get parameters from attributes hash
|
||||
cols = attributes['cols']
|
||||
cols_expr = cols ? ExpressionCompiler.compile(cols, compiler) : "nil"
|
||||
|
||||
# Evaluate the collection
|
||||
code.line "#{coll_var} = #{collection_expr}"
|
||||
code.line "#{coll_var} = #{coll_var}.to_a if #{coll_var}.is_a?(Range)"
|
||||
|
||||
# Handle limit and offset
|
||||
limit = tag.instance_variable_get(:@limit)
|
||||
offset = tag.instance_variable_get(:@offset)
|
||||
# Handle limit and offset from attributes
|
||||
offset = attributes['offset']
|
||||
limit = attributes['limit']
|
||||
if offset || limit
|
||||
if offset
|
||||
offset_expr = ExpressionCompiler.compile(offset, compiler)
|
||||
@@ -54,15 +55,12 @@ module Liquid
|
||||
|
||||
body = tag.instance_variable_get(:@body)
|
||||
|
||||
# Output initial row (matches interpreter behavior: outputs before loop)
|
||||
code.line "__output__ << \"<tr class=\\\"row1\\\">\\n\""
|
||||
|
||||
# The loop
|
||||
code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|"
|
||||
code.indent do
|
||||
# Start new row if needed
|
||||
code.line "if #{col_var} == 0"
|
||||
code.indent do
|
||||
code.line "__output__ << \"<tr class=\\\"row\#{#{row_var}}\\\">\""
|
||||
end
|
||||
code.line "end"
|
||||
code.line "#{col_var} += 1"
|
||||
|
||||
# Output cell start
|
||||
@@ -93,10 +91,10 @@ module Liquid
|
||||
# Output cell end
|
||||
code.line "__output__ << '</td>'"
|
||||
|
||||
# End row if needed
|
||||
code.line "if #{col_var} == #{cols_var}"
|
||||
# End row and start new row if needed (but not on last item)
|
||||
code.line "if #{col_var} == #{cols_var} && #{idx_var} != #{len_var} - 1"
|
||||
code.indent do
|
||||
code.line "__output__ << '</tr>'"
|
||||
code.line "__output__ << \"</tr>\\n<tr class=\\\"row\#{#{row_var} + 1}\\\">\""
|
||||
code.line "#{col_var} = 0"
|
||||
code.line "#{row_var} += 1"
|
||||
end
|
||||
@@ -106,12 +104,8 @@ module Liquid
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Close any open row
|
||||
code.line "if #{col_var} > 0"
|
||||
code.indent do
|
||||
code.line "__output__ << '</tr>'"
|
||||
end
|
||||
code.line "end"
|
||||
# Close the final row
|
||||
code.line "__output__ << \"</tr>\\n\""
|
||||
|
||||
# Clean up
|
||||
code.line "assigns.delete(#{var_name.inspect})"
|
||||
|
||||
+13
-1
@@ -242,12 +242,24 @@ module Liquid
|
||||
# * Custom tags need explicit compiler implementations
|
||||
# * Custom filters must be available at runtime
|
||||
#
|
||||
# Returns a CompiledTemplate object with the Ruby code and any external tags
|
||||
# that need to be passed to the generated lambda.
|
||||
#
|
||||
# Usage:
|
||||
# compiled = template.compile_to_ruby
|
||||
# result = compiled.call({ "name" => "World" }) # Handles external tags automatically
|
||||
#
|
||||
# Or manually:
|
||||
# proc = eval(compiled.code)
|
||||
# result = proc.call(assigns, compiled.external_tags)
|
||||
#
|
||||
def compile_to_ruby(options = {})
|
||||
return nil if @root.nil?
|
||||
|
||||
require 'liquid/compile'
|
||||
compiler = Compile::RubyCompiler.new(self, options)
|
||||
compiler.compile
|
||||
code = compiler.compile
|
||||
Compile::CompiledTemplate.new(code, compiler.external_tags, compiler.has_external_filters?)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
Reference in New Issue
Block a user