diff --git a/lib/liquid/compile.rb b/lib/liquid/compile.rb index 805e3bd5..680eb152 100644 --- a/lib/liquid/compile.rb +++ b/lib/liquid/compile.rb @@ -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' diff --git a/lib/liquid/compile/compiled_template.rb b/lib/liquid/compile/compiled_template.rb new file mode 100644 index 00000000..9372c820 --- /dev/null +++ b/lib/liquid/compile/compiled_template.rb @@ -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 diff --git a/lib/liquid/compile/expression_compiler.rb b/lib/liquid/compile/expression_compiler.rb index fcec1e7b..db0adcc5 100644 --- a/lib/liquid/compile/expression_compiler.rb +++ b/lib/liquid/compile/expression_compiler.rb @@ -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 diff --git a/lib/liquid/compile/filter_compiler.rb b/lib/liquid/compile/filter_compiler.rb index 64dd1149..bc5c9284 100644 --- a/lib/liquid/compile/filter_compiler.rb +++ b/lib/liquid/compile/filter_compiler.rb @@ -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 diff --git a/lib/liquid/compile/ruby_compiler.rb b/lib/liquid/compile/ruby_compiler.rb index e49ffad9..54bf2a77 100644 --- a/lib/liquid/compile/ruby_compiler.rb +++ b/lib/liquid/compile/ruby_compiler.rb @@ -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 diff --git a/lib/liquid/compile/tags/capture_compiler.rb b/lib/liquid/compile/tags/capture_compiler.rb index 21520234..6b6fe30a 100644 --- a/lib/liquid/compile/tags/capture_compiler.rb +++ b/lib/liquid/compile/tags/capture_compiler.rb @@ -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 diff --git a/lib/liquid/compile/tags/include_compiler.rb b/lib/liquid/compile/tags/include_compiler.rb index ea71d631..361bdc0a 100644 --- a/lib/liquid/compile/tags/include_compiler.rb +++ b/lib/liquid/compile/tags/include_compiler.rb @@ -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 diff --git a/lib/liquid/compile/tags/render_compiler.rb b/lib/liquid/compile/tags/render_compiler.rb index e59659f3..c30255a5 100644 --- a/lib/liquid/compile/tags/render_compiler.rb +++ b/lib/liquid/compile/tags/render_compiler.rb @@ -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 diff --git a/lib/liquid/compile/tags/tablerow_compiler.rb b/lib/liquid/compile/tags/tablerow_compiler.rb index 41db922a..af1a1713 100644 --- a/lib/liquid/compile/tags/tablerow_compiler.rb +++ b/lib/liquid/compile/tags/tablerow_compiler.rb @@ -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__ << \"
hello
" }) + compiled = template.compile_to_ruby + assert_equal "<p>hello</p>", compiled.call({ "html" => "hello
" }) 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