mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -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
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Benchmark comparing compiled Ruby code vs interpreted Liquid rendering
|
||||
#
|
||||
# Usage:
|
||||
# ruby performance/compile_benchmark.rb
|
||||
#
|
||||
# This benchmark compares:
|
||||
# 1. Standard Liquid render (interpreted)
|
||||
# 2. Compiled Ruby render (compiled once, executed many times)
|
||||
# 3. Compile + render (includes compilation overhead)
|
||||
|
||||
require 'benchmark/ips'
|
||||
require_relative 'ruby33_compat' # Add peek_byte for Ruby 3.3 compatibility
|
||||
require_relative 'shopify/liquid'
|
||||
require_relative 'shopify/database'
|
||||
|
||||
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
|
||||
|
||||
# Combined filter handler that includes all Shopify filters
|
||||
class ShopifyFilterHandler
|
||||
include JsonFilter
|
||||
include MoneyFilter
|
||||
include ShopFilter
|
||||
include TagFilter
|
||||
include WeightFilter
|
||||
end
|
||||
|
||||
class CompileBenchmarkRunner
|
||||
def initialize
|
||||
@templates = []
|
||||
@compiled_procs = []
|
||||
@filter_handler = ShopifyFilterHandler.new
|
||||
|
||||
# Load test templates
|
||||
load_templates
|
||||
end
|
||||
|
||||
def load_templates
|
||||
puts "Loading templates..."
|
||||
|
||||
test_dirs = Dir[__dir__ + '/tests/*']
|
||||
test_dirs.each do |dir|
|
||||
next unless File.directory?(dir)
|
||||
|
||||
Dir[dir + '/*.liquid'].each do |file|
|
||||
next if File.basename(file) == 'theme.liquid'
|
||||
|
||||
source = File.read(file)
|
||||
template = Liquid::Template.parse(source)
|
||||
|
||||
@templates << {
|
||||
name: File.basename(file),
|
||||
source: source,
|
||||
template: template,
|
||||
assigns: Database.tables.dup,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
puts "Loaded #{@templates.size} templates"
|
||||
|
||||
# Pre-compile all templates
|
||||
puts "Pre-compiling templates to Ruby..."
|
||||
@templates.each do |t|
|
||||
begin
|
||||
compiled = t[:template].compile_to_ruby
|
||||
compiled.filter_handler = @filter_handler # Set the filter handler
|
||||
t[:compiled] = compiled # CompiledTemplate object
|
||||
t[:ruby_code] = compiled.code
|
||||
notes = []
|
||||
notes << "#{compiled.external_tags.size} external tag(s)" if compiled.has_external_tags?
|
||||
notes << "external filters" if compiled.has_external_filters?
|
||||
puts " #{t[:name]}: #{notes.join(', ')}" unless notes.empty?
|
||||
rescue => e
|
||||
puts " Warning: Failed to compile #{t[:name]}: #{e.message}"
|
||||
puts " #{e.backtrace.first(3).join("\n ")}"
|
||||
t[:compiled] = nil
|
||||
end
|
||||
end
|
||||
|
||||
compilable = @templates.count { |t| t[:compiled] }
|
||||
puts "Successfully compiled #{compilable}/#{@templates.size} templates"
|
||||
puts
|
||||
end
|
||||
|
||||
# Benchmark: Standard Liquid render
|
||||
def render_interpreted
|
||||
@templates.each do |t|
|
||||
t[:template].render!(t[:assigns].dup)
|
||||
end
|
||||
end
|
||||
|
||||
# Benchmark: Compiled Ruby render (already compiled)
|
||||
def render_compiled
|
||||
@templates.each do |t|
|
||||
next unless t[:compiled]
|
||||
t[:compiled].call(t[:assigns].dup)
|
||||
end
|
||||
end
|
||||
|
||||
# Benchmark: Compile + render (includes compilation time)
|
||||
def compile_and_render
|
||||
@templates.each do |t|
|
||||
compiled = t[:template].compile_to_ruby
|
||||
compiled.filter_handler = @filter_handler
|
||||
compiled.call(t[:assigns].dup)
|
||||
end
|
||||
end
|
||||
|
||||
# Show sample output comparison
|
||||
def verify_output
|
||||
puts "Verifying output equivalence..."
|
||||
|
||||
@templates.each do |t|
|
||||
next unless t[:compiled]
|
||||
|
||||
assigns = t[:assigns].dup
|
||||
interpreted = t[:template].render!(assigns.dup)
|
||||
compiled = t[:compiled].call(assigns.dup)
|
||||
|
||||
if interpreted == compiled
|
||||
puts " ✓ #{t[:name]}: outputs match"
|
||||
else
|
||||
puts " ✗ #{t[:name]}: outputs differ!"
|
||||
puts " Interpreted length: #{interpreted.length}"
|
||||
puts " Compiled length: #{compiled.length}"
|
||||
|
||||
# Show first difference
|
||||
interpreted.chars.each_with_index do |c, i|
|
||||
if compiled[i] != c
|
||||
puts " First diff at position #{i}:"
|
||||
puts " Interpreted: #{interpreted[i-10, 30].inspect}"
|
||||
puts " Compiled: #{compiled[i-10, 30].inspect}"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
puts
|
||||
end
|
||||
|
||||
# Show code size comparison
|
||||
def show_stats
|
||||
puts "Template Statistics:"
|
||||
puts "=" * 60
|
||||
|
||||
total_liquid_size = 0
|
||||
total_ruby_size = 0
|
||||
|
||||
@templates.each do |t|
|
||||
next unless t[:ruby_code]
|
||||
|
||||
liquid_size = t[:source].length
|
||||
ruby_size = t[:ruby_code].length
|
||||
|
||||
total_liquid_size += liquid_size
|
||||
total_ruby_size += ruby_size
|
||||
|
||||
ratio = ruby_size.to_f / liquid_size
|
||||
puts " #{t[:name]}: Liquid=#{liquid_size}b, Ruby=#{ruby_size}b (#{ratio.round(1)}x)"
|
||||
end
|
||||
|
||||
puts "-" * 60
|
||||
puts " Total: Liquid=#{total_liquid_size}b, Ruby=#{total_ruby_size}b"
|
||||
puts
|
||||
end
|
||||
|
||||
def run_benchmark
|
||||
puts "Running benchmark..."
|
||||
puts "=" * 60
|
||||
puts
|
||||
|
||||
Benchmark.ips do |x|
|
||||
x.time = 10
|
||||
x.warmup = 5
|
||||
|
||||
x.report("Liquid render (interpreted):") do
|
||||
render_interpreted
|
||||
end
|
||||
|
||||
x.report("Ruby render (pre-compiled):") do
|
||||
render_compiled
|
||||
end
|
||||
|
||||
x.report("Compile + render:") do
|
||||
compile_and_render
|
||||
end
|
||||
|
||||
x.compare!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Run the benchmark
|
||||
runner = CompileBenchmarkRunner.new
|
||||
runner.show_stats
|
||||
runner.verify_output
|
||||
runner.run_benchmark
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Compatibility shim for Ruby 3.3
|
||||
# The Liquid library uses peek_byte and scan_byte which are only available in Ruby 3.4+
|
||||
|
||||
require 'strscan'
|
||||
|
||||
unless StringScanner.method_defined?(:peek_byte)
|
||||
class StringScanner
|
||||
def peek_byte
|
||||
return nil if eos?
|
||||
string.getbyte(pos)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
unless StringScanner.method_defined?(:scan_byte)
|
||||
class StringScanner
|
||||
def scan_byte
|
||||
return nil if eos?
|
||||
byte = string.getbyte(pos)
|
||||
self.pos += 1
|
||||
byte
|
||||
end
|
||||
end
|
||||
end
|
||||
+256
-134
@@ -2,292 +2,276 @@
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
# Ruby 3.3 compatibility for peek_byte and scan_byte
|
||||
require 'strscan'
|
||||
unless StringScanner.method_defined?(:peek_byte)
|
||||
class StringScanner
|
||||
def peek_byte
|
||||
return nil if eos?
|
||||
string.getbyte(pos)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
unless StringScanner.method_defined?(:scan_byte)
|
||||
class StringScanner
|
||||
def scan_byte
|
||||
return nil if eos?
|
||||
byte = string.getbyte(pos)
|
||||
self.pos += 1
|
||||
byte
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class CompileTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_compile_simple_string
|
||||
template = Template.parse("Hello, World!")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "Hello, World!", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "Hello, World!", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_variable
|
||||
template = Template.parse("Hello, {{ name }}!")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "Hello, World!", render_proc.call({ "name" => "World" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
|
||||
end
|
||||
|
||||
def test_compile_variable_with_filter
|
||||
template = Template.parse("{{ name | upcase }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "WORLD", render_proc.call({ "name" => "world" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "WORLD", compiled.call({ "name" => "world" })
|
||||
end
|
||||
|
||||
def test_compile_variable_with_multiple_filters
|
||||
template = Template.parse("{{ name | downcase | capitalize }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "Hello", render_proc.call({ "name" => "HELLO" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "Hello", compiled.call({ "name" => "HELLO" })
|
||||
end
|
||||
|
||||
def test_compile_if_true
|
||||
template = Template.parse("{% if show %}visible{% endif %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "visible", render_proc.call({ "show" => true })
|
||||
assert_equal "", render_proc.call({ "show" => false })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "visible", compiled.call({ "show" => true })
|
||||
assert_equal "", compiled.call({ "show" => false })
|
||||
end
|
||||
|
||||
def test_compile_if_else
|
||||
template = Template.parse("{% if show %}yes{% else %}no{% endif %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "yes", render_proc.call({ "show" => true })
|
||||
assert_equal "no", render_proc.call({ "show" => false })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "yes", compiled.call({ "show" => true })
|
||||
assert_equal "no", compiled.call({ "show" => false })
|
||||
end
|
||||
|
||||
def test_compile_if_elsif
|
||||
template = Template.parse("{% if x == 1 %}one{% elsif x == 2 %}two{% else %}other{% endif %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "one", render_proc.call({ "x" => 1 })
|
||||
assert_equal "two", render_proc.call({ "x" => 2 })
|
||||
assert_equal "other", render_proc.call({ "x" => 3 })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "one", compiled.call({ "x" => 1 })
|
||||
assert_equal "two", compiled.call({ "x" => 2 })
|
||||
assert_equal "other", compiled.call({ "x" => 3 })
|
||||
end
|
||||
|
||||
def test_compile_unless
|
||||
template = Template.parse("{% unless hidden %}visible{% endunless %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "visible", render_proc.call({ "hidden" => false })
|
||||
assert_equal "", render_proc.call({ "hidden" => true })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "visible", compiled.call({ "hidden" => false })
|
||||
assert_equal "", compiled.call({ "hidden" => true })
|
||||
end
|
||||
|
||||
def test_compile_for_loop
|
||||
template = Template.parse("{% for item in items %}{{ item }} {% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "a b c ", render_proc.call({ "items" => ["a", "b", "c"] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "a b c ", compiled.call({ "items" => ["a", "b", "c"] })
|
||||
end
|
||||
|
||||
def test_compile_for_loop_with_forloop
|
||||
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "1:a 2:b 3:c ", render_proc.call({ "items" => ["a", "b", "c"] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "1:a 2:b 3:c ", compiled.call({ "items" => ["a", "b", "c"] })
|
||||
end
|
||||
|
||||
def test_compile_for_loop_else
|
||||
template = Template.parse("{% for item in items %}{{ item }}{% else %}empty{% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "abc", render_proc.call({ "items" => ["a", "b", "c"] })
|
||||
assert_equal "empty", render_proc.call({ "items" => [] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "abc", compiled.call({ "items" => ["a", "b", "c"] })
|
||||
assert_equal "empty", compiled.call({ "items" => [] })
|
||||
end
|
||||
|
||||
def test_compile_for_with_limit
|
||||
template = Template.parse("{% for item in items limit:2 %}{{ item }}{% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "ab", render_proc.call({ "items" => ["a", "b", "c", "d"] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "ab", compiled.call({ "items" => ["a", "b", "c", "d"] })
|
||||
end
|
||||
|
||||
def test_compile_assign
|
||||
template = Template.parse("{% assign x = 'hello' %}{{ x }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "hello", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "hello", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_assign_with_filter
|
||||
template = Template.parse("{% assign x = name | upcase %}{{ x }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "WORLD", render_proc.call({ "name" => "world" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "WORLD", compiled.call({ "name" => "world" })
|
||||
end
|
||||
|
||||
def test_compile_capture
|
||||
template = Template.parse("{% capture greeting %}Hello, {{ name }}!{% endcapture %}{{ greeting }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "Hello, World!", render_proc.call({ "name" => "World" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
|
||||
end
|
||||
|
||||
def test_compile_case
|
||||
template = Template.parse("{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "one", render_proc.call({ "x" => 1 })
|
||||
assert_equal "two", render_proc.call({ "x" => 2 })
|
||||
assert_equal "other", render_proc.call({ "x" => 3 })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "one", compiled.call({ "x" => 1 })
|
||||
assert_equal "two", compiled.call({ "x" => 2 })
|
||||
assert_equal "other", compiled.call({ "x" => 3 })
|
||||
end
|
||||
|
||||
def test_compile_raw
|
||||
template = Template.parse("{% raw %}{{ not_a_variable }}{% endraw %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "{{ not_a_variable }}", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "{{ not_a_variable }}", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_comment
|
||||
template = Template.parse("before{% comment %}hidden{% endcomment %}after")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "beforeafter", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "beforeafter", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_increment
|
||||
template = Template.parse("{% increment x %}{% increment x %}{% increment x %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "012", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "012", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_decrement
|
||||
template = Template.parse("{% decrement x %}{% decrement x %}{% decrement x %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "-1-2-3", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "-1-2-3", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_cycle
|
||||
template = Template.parse("{% for i in (1..3) %}{% cycle 'a', 'b' %}{% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "aba", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "aba", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_nested_property_access
|
||||
template = Template.parse("{{ user.profile.name }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
compiled = template.compile_to_ruby
|
||||
data = { "user" => { "profile" => { "name" => "Alice" } } }
|
||||
assert_equal "Alice", render_proc.call(data)
|
||||
assert_equal "Alice", compiled.call(data)
|
||||
end
|
||||
|
||||
def test_compile_array_access
|
||||
template = Template.parse("{{ items[1] }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "b", render_proc.call({ "items" => ["a", "b", "c"] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "b", compiled.call({ "items" => ["a", "b", "c"] })
|
||||
end
|
||||
|
||||
def test_compile_size_filter
|
||||
template = Template.parse("{{ items | size }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "3", render_proc.call({ "items" => [1, 2, 3] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "3", compiled.call({ "items" => [1, 2, 3] })
|
||||
end
|
||||
|
||||
def test_compile_join_filter
|
||||
template = Template.parse("{{ items | join: ', ' }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "a, b, c", render_proc.call({ "items" => ["a", "b", "c"] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "a, b, c", compiled.call({ "items" => ["a", "b", "c"] })
|
||||
end
|
||||
|
||||
def test_compile_split_filter
|
||||
template = Template.parse("{% assign arr = str | split: ',' %}{{ arr | size }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "3", render_proc.call({ "str" => "a,b,c" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "3", compiled.call({ "str" => "a,b,c" })
|
||||
end
|
||||
|
||||
def test_compile_math_filters
|
||||
template = Template.parse("{{ x | plus: 5 | minus: 2 | times: 3 }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "24", render_proc.call({ "x" => 5 })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "24", compiled.call({ "x" => 5 })
|
||||
end
|
||||
|
||||
def test_compile_default_filter
|
||||
template = Template.parse("{{ x | default: 'nothing' }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "hello", render_proc.call({ "x" => "hello" })
|
||||
assert_equal "nothing", render_proc.call({ "x" => nil })
|
||||
assert_equal "nothing", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "hello", compiled.call({ "x" => "hello" })
|
||||
assert_equal "nothing", compiled.call({ "x" => nil })
|
||||
assert_equal "nothing", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_first_last_filters
|
||||
template = Template.parse("{{ items | first }}-{{ items | last }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "a-c", render_proc.call({ "items" => ["a", "b", "c"] })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "a-c", compiled.call({ "items" => ["a", "b", "c"] })
|
||||
end
|
||||
|
||||
def test_compile_escape_filter
|
||||
template = Template.parse("{{ html | escape }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "<p>hello</p>", render_proc.call({ "html" => "<p>hello</p>" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "<p>hello</p>", compiled.call({ "html" => "<p>hello</p>" })
|
||||
end
|
||||
|
||||
def test_compile_replace_filter
|
||||
template = Template.parse("{{ str | replace: 'foo', 'bar' }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "bar baz bar", render_proc.call({ "str" => "foo baz foo" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "bar baz bar", compiled.call({ "str" => "foo baz foo" })
|
||||
end
|
||||
|
||||
def test_compile_append_prepend_filters
|
||||
template = Template.parse("{{ name | prepend: 'Hello, ' | append: '!' }}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "Hello, World!", render_proc.call({ "name" => "World" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
|
||||
end
|
||||
|
||||
def test_compile_for_break
|
||||
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "12", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "12", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_for_continue
|
||||
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "1245", render_proc.call({})
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "1245", compiled.call({})
|
||||
end
|
||||
|
||||
def test_compile_comparison_operators
|
||||
template = Template.parse("{% if x > 5 %}big{% elsif x == 5 %}five{% else %}small{% endif %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "big", render_proc.call({ "x" => 10 })
|
||||
assert_equal "five", render_proc.call({ "x" => 5 })
|
||||
assert_equal "small", render_proc.call({ "x" => 2 })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "big", compiled.call({ "x" => 10 })
|
||||
assert_equal "five", compiled.call({ "x" => 5 })
|
||||
assert_equal "small", compiled.call({ "x" => 2 })
|
||||
end
|
||||
|
||||
def test_compile_and_or_operators
|
||||
template = Template.parse("{% if a and b %}both{% elsif a or b %}one{% else %}none{% endif %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "both", render_proc.call({ "a" => true, "b" => true })
|
||||
assert_equal "one", render_proc.call({ "a" => true, "b" => false })
|
||||
assert_equal "none", render_proc.call({ "a" => false, "b" => false })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "both", compiled.call({ "a" => true, "b" => true })
|
||||
assert_equal "one", compiled.call({ "a" => true, "b" => false })
|
||||
assert_equal "none", compiled.call({ "a" => false, "b" => false })
|
||||
end
|
||||
|
||||
def test_compile_contains
|
||||
template = Template.parse("{% if str contains 'hello' %}found{% else %}not found{% endif %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
assert_equal "found", render_proc.call({ "str" => "say hello world" })
|
||||
assert_equal "not found", render_proc.call({ "str" => "goodbye" })
|
||||
compiled = template.compile_to_ruby
|
||||
assert_equal "found", compiled.call({ "str" => "say hello world" })
|
||||
assert_equal "not found", compiled.call({ "str" => "goodbye" })
|
||||
end
|
||||
|
||||
def test_compile_produces_valid_ruby
|
||||
template = Template.parse("{% for item in items %}{{ item | upcase }}{% endfor %}")
|
||||
ruby_code = template.compile_to_ruby
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
# Should produce valid Ruby syntax
|
||||
assert_nothing_raised do
|
||||
eval(ruby_code)
|
||||
end
|
||||
# Should produce valid Ruby syntax - if eval succeeds without error, it's valid
|
||||
proc = eval(compiled.code)
|
||||
assert_kind_of Proc, proc
|
||||
end
|
||||
|
||||
def test_compile_vs_render_equivalence
|
||||
@@ -306,14 +290,152 @@ class CompileTest < Minitest::Test
|
||||
|
||||
templates.each do |source|
|
||||
template = Template.parse(source)
|
||||
ruby_code = template.compile_to_ruby
|
||||
render_proc = eval(ruby_code)
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
assigns_list.each do |assigns|
|
||||
expected = template.render(assigns)
|
||||
actual = render_proc.call(assigns.dup)
|
||||
actual = compiled.call(assigns.dup)
|
||||
assert_equal expected, actual, "Mismatch for template '#{source}' with assigns #{assigns}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_compiled_template_class
|
||||
template = Template.parse("Hello, {{ name }}!")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
assert_instance_of Liquid::Compile::CompiledTemplate, compiled
|
||||
assert_respond_to compiled, :call
|
||||
assert_respond_to compiled, :code
|
||||
assert_respond_to compiled, :to_s
|
||||
assert_respond_to compiled, :to_proc
|
||||
end
|
||||
|
||||
def test_compiled_template_code
|
||||
template = Template.parse("Hello!")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
assert_kind_of String, compiled.code
|
||||
assert_includes compiled.code, "__output__"
|
||||
assert_includes compiled.code, "Hello!"
|
||||
end
|
||||
|
||||
def test_external_tags_indicator
|
||||
# A simple template should have no external tags
|
||||
template = Template.parse("Hello!")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
assert_equal false, compiled.has_external_tags?
|
||||
assert_empty compiled.external_tags
|
||||
end
|
||||
|
||||
def test_external_filters_indicator
|
||||
# A simple template should have no external filters
|
||||
template = Template.parse("{{ name | upcase }}")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
assert_equal false, compiled.has_external_filters?
|
||||
end
|
||||
|
||||
def test_comprehensive_output_equivalence
|
||||
# Comprehensive test comparing compiled vs interpreted output
|
||||
test_cases = [
|
||||
# Basic variables
|
||||
{ source: "{{ x }}", assigns: { "x" => "hello" } },
|
||||
{ source: "{{ x }}", assigns: { "x" => 123 } },
|
||||
{ source: "{{ x }}", assigns: { "x" => nil } },
|
||||
|
||||
# Nested access
|
||||
{ source: "{{ a.b.c }}", assigns: { "a" => { "b" => { "c" => "deep" } } } },
|
||||
{ source: "{{ items[0] }}", assigns: { "items" => ["first", "second"] } },
|
||||
|
||||
# Filters
|
||||
{ source: "{{ x | upcase }}", assigns: { "x" => "hello" } },
|
||||
{ source: "{{ x | size }}", assigns: { "x" => [1, 2, 3] } },
|
||||
{ source: "{{ x | default: 'fallback' }}", assigns: { "x" => nil } },
|
||||
{ source: "{{ x | plus: 10 }}", assigns: { "x" => 5 } },
|
||||
{ source: "{{ x | split: ',' | first }}", assigns: { "x" => "a,b,c" } },
|
||||
|
||||
# Conditionals
|
||||
{ source: "{% if x %}yes{% endif %}", assigns: { "x" => true } },
|
||||
{ source: "{% if x %}yes{% endif %}", assigns: { "x" => false } },
|
||||
{ source: "{% if x > 5 %}big{% else %}small{% endif %}", assigns: { "x" => 10 } },
|
||||
|
||||
# Loops
|
||||
{ source: "{% for i in items %}{{ i }}{% endfor %}", assigns: { "items" => [1, 2, 3] } },
|
||||
{ source: "{% for i in (1..3) %}{{ i }}{% endfor %}", assigns: {} },
|
||||
{ source: "{% for i in items %}{{ forloop.index }}{% endfor %}", assigns: { "items" => %w[a b] } },
|
||||
{ source: "{% for i in items %}{% if forloop.first %}first{% endif %}{% endfor %}", assigns: { "items" => [1, 2] } },
|
||||
|
||||
# Assignments
|
||||
{ source: "{% assign y = x | upcase %}{{ y }}", assigns: { "x" => "hello" } },
|
||||
{ source: "{% capture c %}Hello {{ x }}{% endcapture %}{{ c }}", assigns: { "x" => "World" } },
|
||||
|
||||
# Case
|
||||
{ source: "{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}", assigns: { "x" => 1 } },
|
||||
{ source: "{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}", assigns: { "x" => 3 } },
|
||||
|
||||
# Increment/Decrement
|
||||
{ source: "{% increment x %}{% increment x %}", assigns: {} },
|
||||
{ source: "{% decrement x %}{% decrement x %}", assigns: {} },
|
||||
|
||||
# Cycle
|
||||
{ source: "{% for i in (1..4) %}{% cycle 'a', 'b' %}{% endfor %}", assigns: {} },
|
||||
|
||||
# Break/Continue
|
||||
{ source: "{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}", assigns: {} },
|
||||
{ source: "{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}", assigns: {} },
|
||||
|
||||
# Raw
|
||||
{ source: "{% raw %}{{ not a var }}{% endraw %}", assigns: {} },
|
||||
|
||||
# Comment
|
||||
{ source: "before{% comment %}hidden{% endcomment %}after", assigns: {} },
|
||||
|
||||
# Tablerow
|
||||
{ source: "{% tablerow i in items cols:2 %}{{ i }}{% endtablerow %}", assigns: { "items" => [1, 2, 3] } },
|
||||
]
|
||||
|
||||
test_cases.each do |tc|
|
||||
source = tc[:source]
|
||||
assigns = tc[:assigns]
|
||||
|
||||
template = Template.parse(source)
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
expected = template.render(assigns.dup)
|
||||
actual = compiled.call(assigns.dup)
|
||||
|
||||
assert_equal expected, actual, "Output mismatch for: #{source.inspect}\nAssigns: #{assigns.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
def test_debug_mode_adds_comments
|
||||
template = Template.parse("{{ name }}")
|
||||
compiled = template.compile_to_ruby(debug: true)
|
||||
|
||||
assert_includes compiled.code, "# LIQUID"
|
||||
assert_includes compiled.code, "# Compiled from Liquid template"
|
||||
end
|
||||
|
||||
def test_filter_handler_can_be_set
|
||||
# Create a custom filter module
|
||||
filter_mod = Module.new do
|
||||
def custom_filter(input)
|
||||
"custom:#{input}"
|
||||
end
|
||||
end
|
||||
|
||||
class_with_filter = Class.new do
|
||||
include filter_mod
|
||||
end
|
||||
|
||||
template = Template.parse("{{ x | custom_filter }}")
|
||||
compiled = template.compile_to_ruby
|
||||
compiled.filter_handler = class_with_filter.new
|
||||
|
||||
# The template should use the custom filter
|
||||
result = compiled.call({ "x" => "test" })
|
||||
assert_equal "custom:test", result
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user