Skip to content

Commit 7be2922

Browse files
committed
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.
1 parent e0f856f commit 7be2922

File tree

13 files changed

+715
-180
lines changed

13 files changed

+715
-180
lines changed

lib/liquid/compile.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
#
4949
module Liquid
5050
module Compile
51+
autoload :CompiledTemplate, 'liquid/compile/compiled_template'
5152
autoload :CodeGenerator, 'liquid/compile/code_generator'
5253
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
5354
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
module Compile
5+
# Represents a compiled Liquid template ready for execution.
6+
#
7+
# Contains the Ruby source code and any external tags/filters that need to be
8+
# passed to the generated lambda at runtime.
9+
#
10+
# Usage:
11+
# compiled = template.compile_to_ruby
12+
# result = compiled.call({ "name" => "World" })
13+
#
14+
# # With custom filters:
15+
# compiled.filter_handler = MyFilterModule
16+
# result = compiled.call({ "name" => "World" })
17+
#
18+
class CompiledTemplate
19+
attr_reader :code, :external_tags
20+
attr_accessor :filter_handler
21+
22+
# @param code [String] The generated Ruby code
23+
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
24+
# @param has_external_filters [Boolean] Whether external filters are used
25+
def initialize(code, external_tags = {}, has_external_filters = false)
26+
@code = code
27+
@external_tags = external_tags
28+
@has_external_filters = has_external_filters
29+
@filter_handler = nil
30+
@proc = nil
31+
end
32+
33+
# Returns true if this template has external tags that need runtime delegation
34+
def has_external_tags?
35+
!@external_tags.empty?
36+
end
37+
38+
# Returns true if this template uses external filters
39+
def has_external_filters?
40+
@has_external_filters
41+
end
42+
43+
# Returns the compiled proc, caching it after first compilation
44+
def to_proc
45+
@proc ||= eval(@code)
46+
end
47+
48+
# Execute the compiled template with the given assigns
49+
# @param assigns [Hash] The variable assignments
50+
# @param filter_handler [Object] Optional filter handler to override the default
51+
# @return [String] The rendered output
52+
def call(assigns = {}, filter_handler: nil)
53+
proc = to_proc
54+
handler = filter_handler || @filter_handler
55+
56+
# Build arguments based on what the lambda expects
57+
args = [assigns]
58+
args << @external_tags if has_external_tags?
59+
args << handler if has_external_filters?
60+
61+
proc.call(*args)
62+
end
63+
64+
# Returns the Ruby code as a string
65+
def to_s
66+
@code
67+
end
68+
end
69+
end
70+
71+
# Make CompiledTemplate available at the top level for convenience
72+
CompiledTemplate = Compile::CompiledTemplate
73+
end

lib/liquid/compile/expression_compiler.rb

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,9 @@ def self.compile_variable_lookup(lookup, compiler)
6868
# Numeric index like foo[0]
6969
base = "__lookup__(#{base}, #{key})"
7070
elsif key.is_a?(String)
71-
# Check if this is a command method (size, first, last)
72-
if lookup.lookup_command?(index)
73-
# Call as method
74-
base = "(#{base}.respond_to?(:#{key}) ? #{base}.#{key} : nil)"
75-
else
76-
# Access as hash/array key
77-
base = "__lookup__(#{base}, #{key.inspect})"
78-
end
71+
# Always use __lookup__ which tries key access first,
72+
# then falls back to method call for command methods (first, last, size)
73+
base = "__lookup__(#{base}, #{key.inspect})"
7974
else
8075
base = "__lookup__(#{base}, #{compile(key, compiler)})"
8176
end

lib/liquid/compile/filter_compiler.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,19 +206,22 @@ def self.compile_filter(input, name, args, kwargs, compiler)
206206
end
207207

208208
# Compile a filter that's not built-in
209+
# Uses __call_filter__ helper which must be provided by the runtime
209210
def self.compile_generic_filter(input, name, args, kwargs, compiler)
211+
# Mark that we're using external filters
212+
compiler.register_external_filter
213+
210214
compiled_args = args.map { |arg| compile_arg(arg, compiler) }
211215

212216
if kwargs && !kwargs.empty?
213217
kwargs_hash = kwargs.map { |k, v| "#{k.inspect} => #{compile_arg(v, compiler)}" }.join(", ")
214218
compiled_args << "{ #{kwargs_hash} }"
215219
end
216220

217-
args_str = compiled_args.empty? ? "" : ", #{compiled_args.join(', ')}"
221+
args_str = compiled_args.empty? ? "[]" : "[#{compiled_args.join(', ')}]"
218222

219-
# Generate a method call - this assumes the filter is available as a method
220-
# In practice, custom filters would need to be defined in the compiled code
221-
"(respond_to?(:#{name}) ? #{name}(#{input}#{args_str}) : #{input})"
223+
# Call through the filter helper which delegates to registered filters
224+
"__call_filter__.call(#{name.inspect}, #{input}, #{args_str})"
222225
end
223226

224227
# Compile a filter argument

lib/liquid/compile/ruby_compiler.rb

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,35 @@ def initialize(template, options = {})
7272
@partials = {} # Registered partials: name => method_name
7373
@partial_sources = {} # Partial sources: name => source code
7474
@partial_counter = 0
75+
@external_tags = {} # External tags: var_name => tag object
76+
@external_tag_counter = 0
77+
@has_external_filters = false # Whether we need the filter helper
78+
end
79+
80+
# Mark that we have external filters
81+
def register_external_filter
82+
@has_external_filters = true
83+
end
84+
85+
# Check if external filters are used
86+
def has_external_filters?
87+
@has_external_filters
88+
end
89+
90+
# Register an external tag that will be called at runtime
91+
# @param tag [Liquid::Tag] The tag to register
92+
# @return [String] The variable name for this tag
93+
def register_external_tag(tag)
94+
@external_tag_counter += 1
95+
var_name = "__ext_tag_#{@external_tag_counter}__"
96+
@external_tags[var_name] = tag
97+
var_name
98+
end
99+
100+
# Get all registered external tags
101+
# @return [Hash] Map of variable names to tag objects
102+
def external_tags
103+
@external_tags
75104
end
76105

77106
# Get the file system for loading partials
@@ -158,6 +187,7 @@ def extract_raw_markup(node)
158187

159188
# Compile the template to a Ruby code string
160189
# @return [String] Ruby code that can be eval'd to create a render proc
190+
# @return [Hash] If external tags are used, returns { code: String, external_tags: Hash }
161191
def compile
162192
code = CodeGenerator.new
163193

@@ -169,8 +199,17 @@ def compile
169199
code.blank_line
170200
end
171201

172-
# Generate the lambda header
173-
code.line "->(assigns = {}) do"
202+
# First pass: compile the document body to discover partials and external tags
203+
main_code = CodeGenerator.new
204+
compile_node(@template.root, main_code)
205+
206+
# Determine lambda parameters based on external dependencies
207+
params = ["assigns = {}"]
208+
params << "__external_tags__ = {}" unless @external_tags.empty?
209+
params << "__filter_handler__ = nil" if @has_external_filters
210+
211+
code.line "->(#{params.join(', ')}) do"
212+
174213
code.indent do
175214
# Initialize the output buffer
176215
code.line '__output__ = +""'
@@ -182,9 +221,17 @@ def compile
182221
code.blank_line
183222
end
184223

185-
# First pass: compile the document body to discover partials
186-
main_code = CodeGenerator.new
187-
compile_node(@template.root, main_code)
224+
# Add external tag runtime helper if needed
225+
unless @external_tags.empty?
226+
compile_external_tag_helper(code)
227+
code.blank_line
228+
end
229+
230+
# Add external filter helper if needed
231+
if @has_external_filters
232+
compile_filter_helper(code)
233+
code.blank_line
234+
end
188235

189236
# Compile partial methods (before main body so they're available)
190237
compile_partials(code)
@@ -200,6 +247,41 @@ def compile
200247
code.to_s
201248
end
202249

250+
# Compile helper for calling external tags at runtime
251+
def compile_external_tag_helper(code)
252+
code.line "# Helper for calling external (unknown) tags at runtime"
253+
code.line "__call_external_tag__ = ->(tag_var, tag_assigns) {"
254+
code.indent do
255+
code.line "tag = __external_tags__[tag_var]"
256+
code.line "next '' unless tag"
257+
code.line "# Create a context using the default environment (which has filters registered)"
258+
code.line "ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)"
259+
code.line "output = +''"
260+
code.line "# Use render_to_output_buffer to ensure block tags work correctly"
261+
code.line "tag.render_to_output_buffer(ctx, output)"
262+
code.line "output"
263+
end
264+
code.line "}"
265+
end
266+
267+
# Compile helper for calling external filters at runtime
268+
def compile_filter_helper(code)
269+
code.line "# Helper for calling external (unknown) filters at runtime"
270+
code.line "__call_filter__ = ->(name, input, args) {"
271+
code.indent do
272+
code.line "if __filter_handler__&.respond_to?(name)"
273+
code.indent do
274+
code.line "__filter_handler__.send(name, input, *args)"
275+
end
276+
code.line "else"
277+
code.indent do
278+
code.line "input # Return input unchanged if filter not found"
279+
end
280+
code.line "end"
281+
end
282+
code.line "}"
283+
end
284+
203285
# Compile all registered partials as inner methods
204286
def compile_partials(code)
205287
@partials.each do |name, method_name|
@@ -293,16 +375,27 @@ def compile_tag(tag, code)
293375
if compiler_class
294376
compiler_class.compile(tag, self, code)
295377
else
296-
raise CompileError, "No compiler for tag: #{tag.class}"
378+
# Unknown tag - delegate to the original tag's render method at runtime
379+
compile_external_tag(tag, code)
297380
end
298381
end
299382

383+
def compile_external_tag(tag, code)
384+
tag_var = register_external_tag(tag)
385+
tag_name = tag.class.name.split('::').last
386+
if debug?
387+
code.line "# External tag: #{tag_name} (delegated to runtime)"
388+
code.line "$stderr.puts '* WARN: Liquid external tag call - #{tag_name} (not compiled, delegated to runtime)' if $VERBOSE"
389+
end
390+
code.line "__output__ << __call_external_tag__.call(#{tag_var.inspect}, assigns)"
391+
end
392+
300393
def find_tag_compiler(tag)
301394
case tag
395+
when Liquid::Unless # Check Unless before If since Unless < If
396+
Tags::UnlessCompiler
302397
when Liquid::If
303398
Tags::IfCompiler
304-
when Liquid::Unless
305-
Tags::UnlessCompiler
306399
when Liquid::Case
307400
Tags::CaseCompiler
308401
when Liquid::For

lib/liquid/compile/tags/capture_compiler.rb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ def self.compile(tag, compiler, code)
1515
code.line "#{capture_var} = __output__"
1616
code.line "__output__ = +''"
1717

18-
# Compile the body
19-
code.indent do
20-
tag.nodelist.each do |body|
21-
BlockBodyCompiler.compile(body, compiler, code)
22-
end
18+
# Compile the body - access the @body BlockBody directly
19+
body = tag.instance_variable_get(:@body)
20+
if body
21+
BlockBodyCompiler.compile(body, compiler, code)
2322
end
2423

2524
# Save captured content and restore output buffer

lib/liquid/compile/tags/include_compiler.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def self.compile_static_include(tag, template_name, compiler, code)
3636
partial_source = compiler.load_partial(template_name)
3737

3838
if partial_source
39+
if compiler.debug?
40+
code.line "# Inlined partial '#{template_name}' at compile time"
41+
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial \\\"#{template_name}\\\" at compile time' if $VERBOSE"
42+
end
3943
# Generate a unique method name for this partial
4044
method_name = compiler.register_partial(template_name, partial_source)
4145
context_var_name = alias_name || template_name.split('/').last
@@ -89,6 +93,11 @@ def self.compile_dynamic_include(tag, compiler, code)
8993
attributes = tag.attributes
9094
alias_name = tag.instance_variable_get(:@alias_name)
9195

96+
if compiler.debug?
97+
code.line "# Dynamic include (template name from variable)"
98+
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic include (template name from variable)' if $VERBOSE"
99+
end
100+
92101
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
93102

94103
# Build attributes hash

lib/liquid/compile/tags/render_compiler.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def self.compile_static_render(tag, template_name, compiler, code)
3838
partial_source = compiler.load_partial(template_name)
3939

4040
if partial_source
41+
if compiler.debug?
42+
code.line "# Inlined partial '#{template_name}' at compile time"
43+
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial \\\"#{template_name}\\\" at compile time' if $VERBOSE"
44+
end
4145
# Generate a unique method name for this partial
4246
method_name = compiler.register_partial(template_name, partial_source)
4347
context_var_name = alias_name || template_name.split('/').last
@@ -146,6 +150,11 @@ def self.compile_dynamic_render(tag, compiler, code)
146150
alias_name = tag.alias_name
147151
is_for_loop = tag.for_loop?
148152

153+
if compiler.debug?
154+
code.line "# Dynamic render (template name from variable)"
155+
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic render (template name from variable)' if $VERBOSE"
156+
end
157+
149158
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
150159

151160
# Build attributes hash

0 commit comments

Comments
 (0)