From e0f856fd1199cf2adf40c068d1dbe160b78990dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 14:21:58 +0000 Subject: [PATCH] Add Liquid to Ruby compiler for optimized template rendering This adds a new `compile_to_ruby` method to Liquid::Template that compiles Liquid templates to pure Ruby code. The compiled code can be eval'd to create a proc that renders templates without needing the Liquid library at runtime. ## Features - Compiles all standard Liquid tags: if/unless/case, for, assign, capture, cycle, increment/decrement, raw, echo, break/continue, comment, tablerow - Compiles variable expressions with filter chains to direct Ruby method calls - Supports static partial inlining ({% render %} and {% include %} with string literals are loaded and compiled at compile time) - Dynamic partial support via runtime callbacks (__render_dynamic__, __include_dynamic__) - Debug mode with source comments for error tracing (lightweight source map) - SourceMapper utility to trace runtime errors back to Liquid source ## Optimization Opportunities The compiled Ruby code has significant performance advantages: 1. No Context object - variables accessed directly from assigns hash 2. No filter invocation overhead - direct Ruby method calls 3. No resource limits tracking - no per-node render score updates 4. No stack-based scoping - uses Ruby's native block scoping 5. Direct string concatenation - no render_to_output_buffer abstraction 6. Native control flow - break/continue use Ruby's throw/catch 7. No to_liquid calls - values used directly 8. No profiling hooks - no profiler overhead 9. No exception rendering - errors propagate naturally ## Usage ```ruby template = Liquid::Template.parse("Hello, {{ name }}!") ruby_code = template.compile_to_ruby render_proc = eval(ruby_code) result = render_proc.call({ "name" => "World" }) # => "Hello, World!" # With debug mode for error tracing: ruby_code = template.compile_to_ruby(debug: true) ``` ## Limitations - Dynamic {% render %} and {% include %} require runtime callback methods - Custom tags need explicit compiler implementations - Custom filters must be available at runtime --- lib/liquid/compile.rb | 81 ++++ lib/liquid/compile/block_body_compiler.rb | 49 ++ lib/liquid/compile/code_generator.rb | 82 ++++ lib/liquid/compile/condition_compiler.rb | 125 +++++ lib/liquid/compile/expression_compiler.rb | 120 +++++ lib/liquid/compile/filter_compiler.rb | 230 ++++++++++ lib/liquid/compile/ruby_compiler.rb | 427 ++++++++++++++++++ lib/liquid/compile/source_mapper.rb | 130 ++++++ lib/liquid/compile/tags/assign_compiler.rb | 19 + lib/liquid/compile/tags/break_compiler.rb | 18 + lib/liquid/compile/tags/capture_compiler.rb | 32 ++ lib/liquid/compile/tags/case_compiler.rb | 47 ++ lib/liquid/compile/tags/comment_compiler.rb | 18 + lib/liquid/compile/tags/continue_compiler.rb | 17 + lib/liquid/compile/tags/cycle_compiler.rb | 45 ++ lib/liquid/compile/tags/decrement_compiler.rb | 26 ++ lib/liquid/compile/tags/echo_compiler.rb | 17 + lib/liquid/compile/tags/for_compiler.rb | 127 ++++++ lib/liquid/compile/tags/if_compiler.rb | 34 ++ lib/liquid/compile/tags/ifchanged_compiler.rb | 40 ++ lib/liquid/compile/tags/include_compiler.rb | 119 +++++ lib/liquid/compile/tags/increment_compiler.rb | 26 ++ lib/liquid/compile/tags/raw_compiler.rb | 19 + lib/liquid/compile/tags/render_compiler.rb | 176 ++++++++ lib/liquid/compile/tags/tablerow_compiler.rb | 123 +++++ lib/liquid/compile/tags/unless_compiler.rb | 38 ++ lib/liquid/compile/variable_compiler.rb | 37 ++ lib/liquid/template.rb | 45 ++ test/unit/compile_test.rb | 319 +++++++++++++ 29 files changed, 2586 insertions(+) create mode 100644 lib/liquid/compile.rb create mode 100644 lib/liquid/compile/block_body_compiler.rb create mode 100644 lib/liquid/compile/code_generator.rb create mode 100644 lib/liquid/compile/condition_compiler.rb create mode 100644 lib/liquid/compile/expression_compiler.rb create mode 100644 lib/liquid/compile/filter_compiler.rb create mode 100644 lib/liquid/compile/ruby_compiler.rb create mode 100644 lib/liquid/compile/source_mapper.rb create mode 100644 lib/liquid/compile/tags/assign_compiler.rb create mode 100644 lib/liquid/compile/tags/break_compiler.rb create mode 100644 lib/liquid/compile/tags/capture_compiler.rb create mode 100644 lib/liquid/compile/tags/case_compiler.rb create mode 100644 lib/liquid/compile/tags/comment_compiler.rb create mode 100644 lib/liquid/compile/tags/continue_compiler.rb create mode 100644 lib/liquid/compile/tags/cycle_compiler.rb create mode 100644 lib/liquid/compile/tags/decrement_compiler.rb create mode 100644 lib/liquid/compile/tags/echo_compiler.rb create mode 100644 lib/liquid/compile/tags/for_compiler.rb create mode 100644 lib/liquid/compile/tags/if_compiler.rb create mode 100644 lib/liquid/compile/tags/ifchanged_compiler.rb create mode 100644 lib/liquid/compile/tags/include_compiler.rb create mode 100644 lib/liquid/compile/tags/increment_compiler.rb create mode 100644 lib/liquid/compile/tags/raw_compiler.rb create mode 100644 lib/liquid/compile/tags/render_compiler.rb create mode 100644 lib/liquid/compile/tags/tablerow_compiler.rb create mode 100644 lib/liquid/compile/tags/unless_compiler.rb create mode 100644 lib/liquid/compile/variable_compiler.rb create mode 100644 test/unit/compile_test.rb diff --git a/lib/liquid/compile.rb b/lib/liquid/compile.rb new file mode 100644 index 00000000..805e3bd5 --- /dev/null +++ b/lib/liquid/compile.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Liquid Ruby Compiler +# +# This module provides the ability to compile Liquid templates to pure Ruby code. +# The compiled code can be eval'd to create a proc that renders the template +# without needing the Liquid library at runtime. +# +# ## Usage +# +# template = Liquid::Template.parse("Hello, {{ name }}!") +# ruby_code = template.compile_to_ruby +# render_proc = eval(ruby_code) +# result = render_proc.call({ "name" => "World" }) +# # => "Hello, World!" +# +# ## Optimization Opportunities +# +# The compiled Ruby code has several significant advantages over interpreted Liquid: +# +# 1. **No Context Object**: Variables are extracted directly from the assigns hash +# and accessed without the Context abstraction layer. +# +# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby method +# calls rather than going through context.invoke(). +# +# 3. **No Resource Limits Tracking**: The compiled code doesn't track render +# scores, write scores, or assign scores, eliminating per-node overhead. +# +# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead +# of manually managing scope stacks. +# +# 5. **Direct String Concatenation**: Output is built with direct << operations. +# +# 6. **Native Control Flow**: break/continue use Ruby's throw/catch mechanism. +# +# 7. **No to_liquid Calls**: Values are used directly without conversion. +# +# 8. **No Profiling Hooks**: No profiler overhead in the generated code. +# +# 9. **No Exception Rendering**: Errors propagate naturally. +# +# ## Limitations +# +# - {% render %} and {% include %} tags require runtime support +# - Custom tags need explicit compiler implementations +# - Custom filters need to be available at runtime +# +module Liquid + module Compile + autoload :CodeGenerator, 'liquid/compile/code_generator' + autoload :RubyCompiler, 'liquid/compile/ruby_compiler' + autoload :ExpressionCompiler, 'liquid/compile/expression_compiler' + autoload :FilterCompiler, 'liquid/compile/filter_compiler' + autoload :VariableCompiler, 'liquid/compile/variable_compiler' + autoload :BlockBodyCompiler, 'liquid/compile/block_body_compiler' + autoload :ConditionCompiler, 'liquid/compile/condition_compiler' + autoload :SourceMapper, 'liquid/compile/source_mapper' + + module Tags + autoload :IfCompiler, 'liquid/compile/tags/if_compiler' + autoload :UnlessCompiler, 'liquid/compile/tags/unless_compiler' + autoload :CaseCompiler, 'liquid/compile/tags/case_compiler' + autoload :ForCompiler, 'liquid/compile/tags/for_compiler' + autoload :AssignCompiler, 'liquid/compile/tags/assign_compiler' + autoload :CaptureCompiler, 'liquid/compile/tags/capture_compiler' + autoload :CycleCompiler, 'liquid/compile/tags/cycle_compiler' + autoload :IncrementCompiler, 'liquid/compile/tags/increment_compiler' + autoload :DecrementCompiler, 'liquid/compile/tags/decrement_compiler' + autoload :RawCompiler, 'liquid/compile/tags/raw_compiler' + autoload :EchoCompiler, 'liquid/compile/tags/echo_compiler' + autoload :BreakCompiler, 'liquid/compile/tags/break_compiler' + autoload :ContinueCompiler, 'liquid/compile/tags/continue_compiler' + autoload :CommentCompiler, 'liquid/compile/tags/comment_compiler' + autoload :TableRowCompiler, 'liquid/compile/tags/tablerow_compiler' + autoload :RenderCompiler, 'liquid/compile/tags/render_compiler' + autoload :IncludeCompiler, 'liquid/compile/tags/include_compiler' + autoload :IfchangedCompiler, 'liquid/compile/tags/ifchanged_compiler' + end + end +end diff --git a/lib/liquid/compile/block_body_compiler.rb b/lib/liquid/compile/block_body_compiler.rb new file mode 100644 index 00000000..317ad54f --- /dev/null +++ b/lib/liquid/compile/block_body_compiler.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # BlockBodyCompiler compiles a BlockBody (a list of nodes) to Ruby code. + # + # A BlockBody contains: + # - String literals (text to output) + # - Variable expressions ({{ ... }}) + # - Tags ({% ... %}) + class BlockBodyCompiler + # Compile a BlockBody to Ruby code + # @param body [Liquid::BlockBody] The block body + # @param compiler [RubyCompiler] The main compiler instance + # @param code [CodeGenerator] The code generator + def self.compile(body, compiler, code) + return if body.nil? + + nodelist = body.nodelist + return if nodelist.nil? || nodelist.empty? + + nodelist.each do |node| + compile_node(node, compiler, code) + end + end + + # Compile a single node + def self.compile_node(node, compiler, code) + case node + when String + compile_string(node, code) + when Variable + VariableCompiler.compile(node, compiler, code) + when Tag + compiler.send(:compile_tag, node, code) + else + raise CompileError, "Unknown node type in BlockBody: #{node.class}" + end + end + + private + + def self.compile_string(str, code) + return if str.empty? + code.line "__output__ << #{str.inspect}" + end + end + end +end diff --git a/lib/liquid/compile/code_generator.rb b/lib/liquid/compile/code_generator.rb new file mode 100644 index 00000000..35a5fc68 --- /dev/null +++ b/lib/liquid/compile/code_generator.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # CodeGenerator provides a clean interface for building Ruby code strings + # with proper indentation and formatting. + class CodeGenerator + INDENT_SIZE = 2 + + def initialize + @lines = [] + @indent_level = 0 + end + + # Add a line of code at the current indentation level + # @param text [String] The code to add + def line(text) + @lines << (" " * @indent_level) + text + end + + # Add a blank line + def blank_line + @lines << "" + end + + # Add multiple lines (useful for multi-line strings) + # @param text [String] Multi-line string to add + def lines(text) + text.each_line do |l| + line(l.chomp) + end + end + + # Increase indentation for a block + def indent + @indent_level += 1 + yield + @indent_level -= 1 + end + + # Get the current indentation string + def current_indent + " " * @indent_level + end + + # Add raw code without indentation adjustment + def raw(text) + @lines << text + end + + # Convert to final Ruby code string + def to_s + @lines.join("\n") + end + + # Generate an inline expression (doesn't add to lines, returns string) + # @param expr [String] The expression + # @return [String] The expression wrapped appropriately + def self.inline(expr) + expr + end + + # Generate a string literal + # @param str [String] The string to escape + # @return [String] Ruby string literal + def self.string_literal(str) + str.inspect + end + + # Generate a safe variable name from a Liquid variable name + # @param name [String] The Liquid variable name + # @return [String] A safe Ruby variable name + def self.safe_var_name(name) + # Replace invalid characters with underscores + safe = name.to_s.gsub(/[^a-zA-Z0-9_]/, '_') + # Ensure it starts with a letter or underscore + safe = "_#{safe}" if safe =~ /\A\d/ + safe + end + end + end +end diff --git a/lib/liquid/compile/condition_compiler.rb b/lib/liquid/compile/condition_compiler.rb new file mode 100644 index 00000000..1c725d95 --- /dev/null +++ b/lib/liquid/compile/condition_compiler.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # ConditionCompiler compiles Liquid conditions to Ruby boolean expressions. + # + # Handles: + # - Simple truthiness: {% if variable %} + # - Comparisons: {% if a == b %}, {% if a > b %} + # - Logical operators: {% if a and b %}, {% if a or b %} + # - Special checks: {% if a == blank %}, {% if a == empty %} + class ConditionCompiler + # Operator mappings from Liquid to Ruby + OPERATORS = { + '==' => '==', + '!=' => '!=', + '<>' => '!=', + '<' => '<', + '>' => '>', + '<=' => '<=', + '>=' => '>=', + 'contains' => :contains, + }.freeze + + # Compile a Condition to a Ruby boolean expression + # @param condition [Liquid::Condition] The condition + # @param compiler [RubyCompiler] The main compiler instance + # @return [String] Ruby code expression that evaluates to true/false + def self.compile(condition, compiler) + if condition.is_a?(ElseCondition) + return "true" + end + + compile_condition_chain(condition, compiler) + end + + private + + def self.compile_condition_chain(condition, compiler) + # Compile the current condition + current = compile_single_condition(condition, compiler) + + # Check for chained conditions (and/or) + if condition.child_condition + child = compile_condition_chain(condition.child_condition, compiler) + child_relation = condition.send(:child_relation) + + case child_relation + when :and + "(#{current} && #{child})" + when :or + "(#{current} || #{child})" + else + current + end + else + current + end + end + + def self.compile_single_condition(condition, compiler) + left = condition.left + op = condition.operator + right = condition.right + + # If no operator, just check truthiness + if op.nil? + left_expr = ExpressionCompiler.compile(left, compiler) + return "__truthy__(#{left_expr})" + end + + # Compile left and right expressions + left_expr = compile_condition_value(left, compiler) + right_expr = compile_condition_value(right, compiler) + + # Handle special operators + case OPERATORS[op] + when :contains + compile_contains(left_expr, right_expr, compiler) + when '==' + compile_equality(left, right, left_expr, right_expr, compiler) + when '!=' + "!(#{compile_equality(left, right, left_expr, right_expr, compiler)})" + else + # Standard comparison + ruby_op = OPERATORS[op] || op + "(#{left_expr} #{ruby_op} #{right_expr} rescue false)" + end + end + + def self.compile_condition_value(expr, compiler) + if expr.is_a?(Condition::MethodLiteral) + # For blank/empty checks, we return a special marker + # The equality handler will deal with this + ":__method_literal_#{expr.method_name}__" + else + ExpressionCompiler.compile(expr, compiler) + end + end + + def self.compile_equality(left, right, left_expr, right_expr, compiler) + # Handle blank/empty method literals + if left.is_a?(Condition::MethodLiteral) + method_name = left.method_name + "(#{right_expr}.respond_to?(:#{method_name}) ? #{right_expr}.#{method_name} : nil)" + elsif right.is_a?(Condition::MethodLiteral) + method_name = right.method_name + "(#{left_expr}.respond_to?(:#{method_name}) ? #{left_expr}.#{method_name} : nil)" + else + "(#{left_expr} == #{right_expr})" + end + end + + def self.compile_contains(left_expr, right_expr, compiler) + # The contains operator checks if left includes right + # For strings, right is converted to a string + "(lambda { |left, right| " \ + "return false if left.nil? || right.nil? || !left.respond_to?(:include?); " \ + "right = right.to_s if left.is_a?(String); " \ + "left.include?(right) rescue false " \ + "}.call(#{left_expr}, #{right_expr}))" + end + end + end +end diff --git a/lib/liquid/compile/expression_compiler.rb b/lib/liquid/compile/expression_compiler.rb new file mode 100644 index 00000000..fcec1e7b --- /dev/null +++ b/lib/liquid/compile/expression_compiler.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # ExpressionCompiler compiles Liquid expressions to Ruby code. + # + # Expressions include: + # - Literals: nil, true, false, strings, numbers + # - Variable lookups: foo, foo.bar, foo[0], foo["key"] + # - Ranges: (1..10), (start..end) + class ExpressionCompiler + # Compile an expression to a Ruby code string + # @param expr [Object] The parsed expression + # @param compiler [RubyCompiler] The main compiler instance + # @return [String] Ruby code that evaluates to the expression value + def self.compile(expr, compiler) + case expr + when nil + "nil" + when true + "true" + when false + "false" + when String + expr.inspect + when Integer, Float + expr.inspect + when Range + "(#{expr.begin.inspect}..#{expr.end.inspect})" + when VariableLookup + compile_variable_lookup(expr, compiler) + when RangeLookup + compile_range_lookup(expr, compiler) + when Condition::MethodLiteral + # Handle blank/empty method literals + compile_method_literal(expr, compiler) + else + raise CompileError, "Unknown expression type: #{expr.class} (#{expr.inspect})" + end + end + + # Compile a variable lookup expression + # @param lookup [VariableLookup] The variable lookup + # @param compiler [RubyCompiler] The main compiler instance + # @return [String] Ruby code that evaluates to the variable value + def self.compile_variable_lookup(lookup, compiler) + # Start with the base variable + name = lookup.name + + # Handle dynamic name (expression in brackets) + base = if name.is_a?(VariableLookup) || name.is_a?(RangeLookup) + # Dynamic name like [expr].foo + "assigns[#{compile(name, compiler)}]" + elsif name.is_a?(String) + "assigns[#{name.inspect}]" + elsif name.is_a?(Integer) + "assigns[#{name.inspect}]" + else + compile(name, compiler) + end + + # Apply each lookup in the chain + lookup.lookups.each_with_index do |key, index| + if key.is_a?(VariableLookup) || key.is_a?(RangeLookup) + # Dynamic key like foo[expr] + base = "__lookup__(#{base}, #{compile(key, compiler)})" + elsif key.is_a?(Integer) + # 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 + else + base = "__lookup__(#{base}, #{compile(key, compiler)})" + end + end + + base + end + + # Compile a range lookup expression + # @param range [RangeLookup] The range lookup + # @param compiler [RubyCompiler] The main compiler instance + # @return [String] Ruby code that evaluates to the range + def self.compile_range_lookup(range, compiler) + start_expr = compile(range.start_obj, compiler) + end_expr = compile(range.end_obj, compiler) + + # Convert to integers and create range + "(__to_integer__(#{start_expr})...__to_integer__(#{end_expr})).to_a" + end + + # Compile a method literal (blank/empty) + def self.compile_method_literal(literal, compiler) + # These are used in conditions like `if foo == blank` + # They represent special method calls + literal.to_s.inspect + end + + # Compile an expression for use in a condition + # @param expr [Object] The expression + # @param compiler [RubyCompiler] The main compiler + # @return [String] Ruby code for the condition value + def self.compile_for_condition(expr, compiler) + if expr.is_a?(Condition::MethodLiteral) + # Return the method name symbol for special handling + ":#{expr.method_name}" + else + compile(expr, compiler) + end + end + end + end +end diff --git a/lib/liquid/compile/filter_compiler.rb b/lib/liquid/compile/filter_compiler.rb new file mode 100644 index 00000000..64dd1149 --- /dev/null +++ b/lib/liquid/compile/filter_compiler.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # FilterCompiler compiles Liquid filter chains to Ruby code. + # + # Filters are applied in sequence: {{ value | filter1: arg1 | filter2: arg2 }} + # becomes a chain of method calls on the value. + class FilterCompiler + # Standard filters that map directly to Ruby methods or simple expressions + SIMPLE_FILTERS = { + 'size' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:size) ? #{input}.size : 0)" }, + 'downcase' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).downcase" }, + 'upcase' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).upcase" }, + 'capitalize' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).capitalize" }, + 'strip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).strip" }, + 'lstrip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).lstrip" }, + 'rstrip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).rstrip" }, + 'reverse' => ->(input, _args, _kwargs, _compiler) { "(#{input}.is_a?(Array) ? #{input}.reverse : __to_s__(#{input}).reverse)" }, + 'first' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:first) ? #{input}.first : nil)" }, + 'last' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:last) ? #{input}.last : nil)" }, + 'uniq' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:uniq) ? #{input}.uniq : #{input})" }, + 'compact' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:compact) ? #{input}.compact : #{input})" }, + 'flatten' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:flatten) ? #{input}.flatten : #{input})" }, + 'sort' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:sort) ? #{input}.sort : #{input})" }, + 'abs' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).abs" }, + 'ceil' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).ceil.to_i" }, + 'floor' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).floor.to_i" }, + 'escape' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escapeHTML(__to_s__(#{input})))" }, + 'h' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escapeHTML(__to_s__(#{input})))" }, + 'url_encode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escape(__to_s__(#{input})))" }, + 'url_decode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.unescape(__to_s__(#{input})))" }, + 'base64_encode' => ->(input, _args, _kwargs, _compiler) { "Base64.strict_encode64(__to_s__(#{input}))" }, + 'base64_decode' => ->(input, _args, _kwargs, _compiler) { "Base64.strict_decode64(__to_s__(#{input}))" }, + 'base64_url_safe_encode' => ->(input, _args, _kwargs, _compiler) { "Base64.urlsafe_encode64(__to_s__(#{input}))" }, + 'base64_url_safe_decode' => ->(input, _args, _kwargs, _compiler) { "Base64.urlsafe_decode64(__to_s__(#{input}))" }, + 'strip_html' => ->(input, _args, _kwargs, _compiler) { + "__to_s__(#{input}).gsub(%r{||}m, '').gsub(/<.*?>/m, '')" + }, + 'strip_newlines' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).gsub(/\\r?\\n/, '')" }, + 'newline_to_br' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).gsub(/\\r?\\n/, \"
\\n\")" }, + }.freeze + + # Filters with arguments that need special handling + PARAMETERIZED_FILTERS = { + 'append' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "__to_s__(#{input}) + __to_s__(#{arg})" + }, + 'prepend' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "__to_s__(#{arg}) + __to_s__(#{input})" + }, + 'plus' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "(__to_number__(#{input}) + __to_number__(#{arg}))" + }, + 'minus' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "(__to_number__(#{input}) - __to_number__(#{arg}))" + }, + 'times' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "(__to_number__(#{input}) * __to_number__(#{arg}))" + }, + 'divided_by' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "(__to_number__(#{input}) / __to_number__(#{arg}))" + }, + 'modulo' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "(__to_number__(#{input}) % __to_number__(#{arg}))" + }, + 'round' => ->(input, args, _kwargs, compiler) { + if args.empty? + "__to_number__(#{input}).round.to_i" + else + arg = compile_arg(args[0], compiler) + "__to_number__(#{input}).round(__to_number__(#{arg}))" + end + }, + 'at_least' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "[__to_number__(#{input}), __to_number__(#{arg})].max" + }, + 'at_most' => ->(input, args, _kwargs, compiler) { + arg = compile_arg(args[0], compiler) + "[__to_number__(#{input}), __to_number__(#{arg})].min" + }, + 'default' => ->(input, args, kwargs, compiler) { + default_val = args.empty? ? "''" : compile_arg(args[0], compiler) + allow_false = kwargs && kwargs['allow_false'] ? compile_arg(kwargs['allow_false'], compiler) : 'false' + "(if #{allow_false} then (#{input}.nil? || (#{input}.respond_to?(:empty?) && #{input}.empty?)) else (!__truthy__(#{input}) || (#{input}.respond_to?(:empty?) && #{input}.empty?)) end) ? #{default_val} : #{input}" + }, + 'split' => ->(input, args, _kwargs, compiler) { + pattern = args.empty? ? "' '" : compile_arg(args[0], compiler) + "__to_s__(#{input}).split(__to_s__(#{pattern}))" + }, + 'join' => ->(input, args, _kwargs, compiler) { + glue = args.empty? ? "' '" : compile_arg(args[0], compiler) + "(#{input}.is_a?(Array) ? #{input}.map { |i| __to_s__(i) }.join(__to_s__(#{glue})) : __to_s__(#{input}))" + }, + 'replace' => ->(input, args, _kwargs, compiler) { + string = compile_arg(args[0], compiler) + replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''" + "__to_s__(#{input}).gsub(__to_s__(#{string}), __to_s__(#{replacement}))" + }, + 'replace_first' => ->(input, args, _kwargs, compiler) { + string = compile_arg(args[0], compiler) + replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''" + "__to_s__(#{input}).sub(__to_s__(#{string}), __to_s__(#{replacement}))" + }, + 'remove' => ->(input, args, _kwargs, compiler) { + string = compile_arg(args[0], compiler) + "__to_s__(#{input}).gsub(__to_s__(#{string}), '')" + }, + 'remove_first' => ->(input, args, _kwargs, compiler) { + string = compile_arg(args[0], compiler) + "__to_s__(#{input}).sub(__to_s__(#{string}), '')" + }, + 'truncate' => ->(input, args, _kwargs, compiler) { + length = args.empty? ? "50" : compile_arg(args[0], compiler) + ellipsis = args.length > 1 ? compile_arg(args[1], compiler) : "'...'" + var = compiler.generate_var_name("trunc") + "(lambda { |#{var}_input, #{var}_len, #{var}_ell| #{var}_str = __to_s__(#{var}_input); #{var}_ell_str = __to_s__(#{var}_ell); #{var}_l = [#{var}_len.to_i - #{var}_ell_str.length, 0].max; #{var}_str.length > #{var}_len.to_i ? #{var}_str[0, #{var}_l] + #{var}_ell_str : #{var}_str }).call(#{input}, #{length}, #{ellipsis})" + }, + 'truncatewords' => ->(input, args, _kwargs, compiler) { + words = args.empty? ? "15" : compile_arg(args[0], compiler) + ellipsis = args.length > 1 ? compile_arg(args[1], compiler) : "'...'" + "(lambda { |input, num_words, ell| words = __to_s__(input).split(' ', [num_words.to_i, 1].max + 1); words.length > [num_words.to_i, 1].max ? words[0, [num_words.to_i, 1].max].join(' ') + __to_s__(ell) : input.to_s }).call(#{input}, #{words}, #{ellipsis})" + }, + 'slice' => ->(input, args, _kwargs, compiler) { + offset = compile_arg(args[0], compiler) + length = args.length > 1 ? compile_arg(args[1], compiler) : "1" + "(#{input}.is_a?(Array) ? (#{input}.slice(__to_integer__(#{offset}), __to_integer__(#{length})) || []) : (__to_s__(#{input}).slice(__to_integer__(#{offset}), __to_integer__(#{length})) || ''))" + }, + 'map' => ->(input, args, _kwargs, compiler) { + property = compile_arg(args[0], compiler) + "(#{input}.is_a?(Array) ? #{input}.map { |item| item.respond_to?(:[]) ? item[#{property}] : nil } : [])" + }, + 'where' => ->(input, args, _kwargs, compiler) { + property = compile_arg(args[0], compiler) + if args.length > 1 + target = compile_arg(args[1], compiler) + "(#{input}.is_a?(Array) ? #{input}.select { |item| item.respond_to?(:[]) && item[#{property}] == #{target} } : [])" + else + "(#{input}.is_a?(Array) ? #{input}.select { |item| item.respond_to?(:[]) && __truthy__(item[#{property}]) } : [])" + end + }, + 'reject' => ->(input, args, _kwargs, compiler) { + property = compile_arg(args[0], compiler) + if args.length > 1 + target = compile_arg(args[1], compiler) + "(#{input}.is_a?(Array) ? #{input}.reject { |item| item.respond_to?(:[]) && item[#{property}] == #{target} } : [])" + else + "(#{input}.is_a?(Array) ? #{input}.reject { |item| item.respond_to?(:[]) && __truthy__(item[#{property}]) } : [])" + end + }, + 'concat' => ->(input, args, _kwargs, compiler) { + arr = compile_arg(args[0], compiler) + "(#{input}.is_a?(Array) ? #{input} + (#{arr}.respond_to?(:to_ary) ? #{arr}.to_ary : []) : [])" + }, + 'sort_natural' => ->(input, args, _kwargs, compiler) { + if args.empty? + "(#{input}.is_a?(Array) ? #{input}.sort_by { |a| a.to_s.downcase } : #{input})" + else + property = compile_arg(args[0], compiler) + "(#{input}.is_a?(Array) ? #{input}.sort_by { |a| a.respond_to?(:[]) ? a[#{property}].to_s.downcase : '' } : #{input})" + end + }, + 'date' => ->(input, args, _kwargs, compiler) { + format = compile_arg(args[0], compiler) + # This is a simplified version - full date parsing is complex + "(lambda { |input, fmt| return input if fmt.to_s.empty?; d = case input; when Time, Date, DateTime then input; when 'now', 'today' then Time.now; when /\\A\\d+\\z/, Integer then Time.at(input.to_i); when String then (Time.parse(input) rescue input); else input; end; d.respond_to?(:strftime) ? d.strftime(fmt.to_s) : input }.call(#{input}, #{format}))" + }, + 'escape_once' => ->(input, _args, _kwargs, _compiler) { + "__to_s__(#{input}).gsub(/[\"><']|&(?!([a-zA-Z]+|(#\\d+));)/) { |c| {'&'=>'&', '>'=>'>', '<'=>'<', '\"'=>'"', \"'\"=>'''}[c] || c }" + }, + }.freeze + + # Compile a filter chain + # @param input_expr [String] Ruby expression for the input value + # @param filters [Array] Array of filter definitions [name, args, kwargs] + # @param compiler [RubyCompiler] The main compiler instance + # @return [String] Ruby code that applies all filters + def self.compile(input_expr, filters, compiler) + result = input_expr + + filters.each do |filter_name, filter_args, filter_kwargs| + result = compile_filter(result, filter_name, filter_args || [], filter_kwargs, compiler) + end + + result + end + + # Compile a single filter application + def self.compile_filter(input, name, args, kwargs, compiler) + if SIMPLE_FILTERS.key?(name) + SIMPLE_FILTERS[name].call(input, args, kwargs, compiler) + elsif PARAMETERIZED_FILTERS.key?(name) + PARAMETERIZED_FILTERS[name].call(input, args, kwargs, compiler) + else + # Fall back to a generic filter call + compile_generic_filter(input, name, args, kwargs, compiler) + end + end + + # Compile a filter that's not built-in + def self.compile_generic_filter(input, name, args, kwargs, compiler) + compiled_args = args.map { |arg| compile_arg(arg, compiler) } + + if kwargs && !kwargs.empty? + kwargs_hash = kwargs.map { |k, v| "#{k.inspect} => #{compile_arg(v, compiler)}" }.join(", ") + compiled_args << "{ #{kwargs_hash} }" + end + + 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})" + end + + # Compile a filter argument + def self.compile_arg(arg, compiler) + ExpressionCompiler.compile(arg, compiler) + end + end + end +end diff --git a/lib/liquid/compile/ruby_compiler.rb b/lib/liquid/compile/ruby_compiler.rb new file mode 100644 index 00000000..e49ffad9 --- /dev/null +++ b/lib/liquid/compile/ruby_compiler.rb @@ -0,0 +1,427 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # RubyCompiler transforms a parsed Liquid template into pure Ruby code. + # + # The compiled code is a string that can be eval'd to create a proc/lambda + # that takes an assigns hash and returns the rendered output string. + # + # ## Optimization Opportunities + # + # The compiled Ruby code has several significant advantages over interpreted Liquid: + # + # 1. **No Context Object**: Instead of using a Context for variable lookups, + # variables are extracted directly from the assigns hash and stored in + # local Ruby variables. This eliminates hash lookups on every access. + # + # 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby + # method calls rather than going through context.invoke(). + # + # 3. **No Resource Limits Tracking**: The compiled code doesn't track render + # scores, write scores, or assign scores, eliminating per-node overhead. + # + # 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead + # of manually managing scope stacks with push/pop operations. + # + # 5. **Direct String Concatenation**: Output is built with direct << operations + # rather than through render_to_output_buffer abstractions. + # + # 6. **Native Control Flow**: break/continue become Ruby's break/next, + # eliminating interrupt objects and checks. + # + # 7. **No to_liquid Calls**: Values are used directly without conversion. + # + # 8. **Potential for Constant Folding**: Expressions with only literals + # could be evaluated at compile time (future enhancement). + # + # 9. **Potential for Dead Code Elimination**: Unreachable branches like + # `{% if false %}` could be removed (future enhancement). + # + # 10. **No Profiling Hooks**: No profiler overhead in the generated code. + # + # 11. **No Exception Rendering**: Errors propagate naturally rather than + # being caught and rendered inline. + # + # ## Usage + # + # template = Liquid::Template.parse("Hello, {{ name }}!") + # ruby_code = template.compile_to_ruby + # render_proc = eval(ruby_code) + # result = render_proc.call({ "name" => "World" }) + # # => "Hello, World!" + # + class RubyCompiler + attr_reader :template, :options + + # @param template [Liquid::Template] The parsed template to compile + # @param options [Hash] Compilation options + # @option options [Boolean] :strict_variables Raise on undefined variables (default: false) + # @option options [Boolean] :include_filters Include filter helper methods (default: true) + # @option options [Boolean] :debug Emit source comments for debugging (default: false) + # @option options [Object] :file_system File system for loading partials (default: template's environment) + def initialize(template, options = {}) + @template = template + @options = { + strict_variables: false, + include_filters: true, + debug: false, + file_system: nil, + }.merge(options) + @var_counter = 0 + @partials = {} # Registered partials: name => method_name + @partial_sources = {} # Partial sources: name => source code + @partial_counter = 0 + end + + # Get the file system for loading partials + def file_system + @options[:file_system] || @template.instance_variable_get(:@environment)&.file_system + end + + # Load a partial source from the file system + # @param name [String] The partial name + # @return [String, nil] The partial source or nil if not found + def load_partial(name) + return @partial_sources[name] if @partial_sources.key?(name) + + fs = file_system + return nil unless fs && fs.respond_to?(:read_template_file) + + begin + source = fs.read_template_file(name) + @partial_sources[name] = source + source + rescue Liquid::FileSystemError + nil + end + end + + # Register a partial and return its method name + # @param name [String] The partial name + # @param source [String] The partial source + # @return [String] The method name for this partial + def register_partial(name, source) + return @partials[name] if @partials.key?(name) + + @partial_counter += 1 + method_name = "__partial_#{@partial_counter}__" + @partials[name] = method_name + method_name + end + + # Get all registered partials + def registered_partials + @partials + end + + # Check if debug mode is enabled + def debug? + @options[:debug] + end + + # Emit a debug comment with source location info + # This creates a lightweight source map that allows tracing errors + # back to the original Liquid source + def emit_debug_comment(code, node, description = nil) + return unless debug? + + line_number = node.respond_to?(:line_number) ? node.line_number : nil + raw_markup = extract_raw_markup(node) + + comment_parts = [] + comment_parts << "LIQUID" + comment_parts << "L#{line_number}" if line_number + comment_parts << description if description + comment_parts << raw_markup.inspect if raw_markup && raw_markup.length < 80 + + code.line "# #{comment_parts.join(' | ')}" + end + + # Extract the raw markup from a node for debug output + def extract_raw_markup(node) + case node + when Variable + "{{ #{node.raw} }}" + when Tag + if node.respond_to?(:raw) + "{% #{node.tag_name} #{node.raw} %}" + elsif node.respond_to?(:markup) + "{% #{node.tag_name} #{node.markup} %}" + else + "{% #{node.class.name.split('::').last.downcase} %}" + end + else + nil + end + end + + # Compile the template to a Ruby code string + # @return [String] Ruby code that can be eval'd to create a render proc + def compile + code = CodeGenerator.new + + # Add debug header if enabled + if debug? + code.line "# Compiled from Liquid template: #{@template.name || '(unnamed)'}" + code.line "# Debug mode enabled - comments contain source locations" + code.line "# Format: # LIQUID | L | | " + code.blank_line + end + + # Generate the lambda header + code.line "->(assigns = {}) do" + code.indent do + # Initialize the output buffer + code.line '__output__ = +""' + code.blank_line + + # Compile helper methods if needed + if @options[:include_filters] + compile_helper_methods(code) + code.blank_line + end + + # First pass: compile the document body to discover partials + main_code = CodeGenerator.new + compile_node(@template.root, main_code) + + # Compile partial methods (before main body so they're available) + compile_partials(code) + + # Add the main body code + code.raw(main_code.to_s) + + code.blank_line + code.line "__output__" + end + code.line "end" + + code.to_s + end + + # Compile all registered partials as inner methods + def compile_partials(code) + @partials.each do |name, method_name| + source = @partial_sources[name] + next unless source + + code.line "# Partial: #{name}" + code.line "#{method_name} = ->(partial_assigns) do" + code.indent do + code.line "__partial_output__ = +''" + code.line "# Merge partial assigns with parent assigns" + code.line "__assigns_backup__ = assigns.dup" + code.line "assigns.merge!(partial_assigns)" + code.blank_line + + # Parse and compile the partial + # Note: We need to handle this carefully to avoid circular references + begin + compile_partial_source(source, code) + rescue => e + code.line "# Error compiling partial: #{e.message.inspect}" + code.line "__partial_output__ << '[PARTIAL ERROR: #{name}]'" + end + + code.blank_line + code.line "# Restore assigns" + code.line "assigns.replace(__assigns_backup__)" + code.line "__partial_output__" + end + code.line "end" + code.blank_line + end + end + + # Compile a partial source string + def compile_partial_source(source, code) + # Parse the partial source using the same environment + environment = @template.instance_variable_get(:@environment) || Liquid::Environment.default + parse_context = Liquid::ParseContext.new(environment: environment) + tokenizer = parse_context.new_tokenizer(source) + document = Liquid::Document.parse(tokenizer, parse_context) + + # Compile the partial's body, but swap output variable + code.line "__saved_output__ = __output__" + code.line "__output__ = __partial_output__" + + # Compile the document + BlockBodyCompiler.compile(document.body, self, code) + + code.line "__output__ = __saved_output__" + end + + # Generate a unique variable name for internal use + def generate_var_name(prefix = "v") + @var_counter += 1 + "__#{prefix}#{@var_counter}__" + end + + # Compile a single node + def compile_node(node, code) + case node + when Document + BlockBodyCompiler.compile(node.body, self, code) + when BlockBody + BlockBodyCompiler.compile(node, self, code) + when String + compile_string(node, code) + when Variable + emit_debug_comment(code, node, "variable") + VariableCompiler.compile(node, self, code) + when Tag + emit_debug_comment(code, node, node.class.name.split('::').last.downcase) + compile_tag(node, code) + else + raise CompileError, "Unknown node type: #{node.class}" + end + end + + private + + def compile_string(str, code) + return if str.empty? + if debug? && str.length < 40 + code.line "# LIQUID | text | #{str.inspect}" + end + code.line "__output__ << #{str.inspect}" + end + + def compile_tag(tag, code) + compiler_class = find_tag_compiler(tag) + if compiler_class + compiler_class.compile(tag, self, code) + else + raise CompileError, "No compiler for tag: #{tag.class}" + end + end + + def find_tag_compiler(tag) + case tag + when Liquid::If + Tags::IfCompiler + when Liquid::Unless + Tags::UnlessCompiler + when Liquid::Case + Tags::CaseCompiler + when Liquid::For + Tags::ForCompiler + when Liquid::Assign + Tags::AssignCompiler + when Liquid::Capture + Tags::CaptureCompiler + when Liquid::Cycle + Tags::CycleCompiler + when Liquid::Increment + Tags::IncrementCompiler + when Liquid::Decrement + Tags::DecrementCompiler + when Liquid::Raw + Tags::RawCompiler + when Liquid::Echo + Tags::EchoCompiler + when Liquid::Break + Tags::BreakCompiler + when Liquid::Continue + Tags::ContinueCompiler + when Liquid::Comment, Liquid::InlineComment, Liquid::Doc + Tags::CommentCompiler + when Liquid::TableRow + Tags::TableRowCompiler + when Liquid::Render + Tags::RenderCompiler + when Liquid::Include + Tags::IncludeCompiler + when Liquid::Ifchanged + Tags::IfchangedCompiler + else + nil + end + end + + def compile_helper_methods(code) + code.line "# Helper methods for filters and utilities" + + # to_s helper that handles arrays and hashes like Liquid does + code.line "def __to_s__(obj)" + code.indent do + code.line "case obj" + code.line "when NilClass then ''" + code.line "when Array then obj.join" + code.line "else obj.to_s" + code.line "end" + end + code.line "end" + code.blank_line + + # to_number helper + code.line "def __to_number__(obj)" + code.indent do + code.line "case obj" + code.line "when Numeric then obj" + code.line "when String" + code.indent do + code.line "obj.strip =~ /\\A-?\\d+\\.\\d+\\z/ ? BigDecimal(obj) : obj.to_i" + end + code.line "else 0" + code.line "end" + end + code.line "end" + code.blank_line + + # to_integer helper + code.line "def __to_integer__(obj)" + code.indent do + code.line "return obj if obj.is_a?(Integer)" + code.line "Integer(obj.to_s)" + end + code.line "end" + code.blank_line + + # Liquid truthiness helper + code.line "def __truthy__(obj)" + code.indent do + code.line "obj != nil && obj != false" + end + code.line "end" + code.blank_line + + # Variable lookup helper + code.line "def __lookup__(obj, key)" + code.indent do + code.line "return nil if obj.nil?" + code.line "if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))" + code.indent do + code.line "obj[key]" + end + code.line "elsif obj.respond_to?(key)" + code.indent do + code.line "obj.send(key)" + end + code.line "else" + code.indent do + code.line "nil" + end + code.line "end" + end + code.line "end" + code.blank_line + + # Output helper that handles nil and arrays + code.line "def __output_value__(obj)" + code.indent do + code.line "case obj" + code.line "when NilClass then ''" + code.line "when Array then obj.map { |o| __output_value__(o) }.join" + code.line "else obj.to_s" + code.line "end" + end + code.line "end" + end + end + + # Custom error for compilation issues + class CompileError < StandardError; end + end +end diff --git a/lib/liquid/compile/source_mapper.rb b/lib/liquid/compile/source_mapper.rb new file mode 100644 index 00000000..2a0840e5 --- /dev/null +++ b/lib/liquid/compile/source_mapper.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # SourceMapper provides utilities for mapping errors in compiled Ruby code + # back to the original Liquid source. + # + # When code is compiled with debug: true, comments are embedded that contain + # source location information. This class can parse those comments and + # help trace errors back to the original Liquid template. + # + # ## Usage + # + # template = Liquid::Template.parse(source, line_numbers: true) + # ruby_code = template.compile_to_ruby(debug: true) + # render_proc = eval(ruby_code) + # + # begin + # result = render_proc.call(assigns) + # rescue => e + # location = SourceMapper.find_source_location(ruby_code, e) + # puts "Error at Liquid line #{location[:liquid_line]}: #{location[:source]}" + # end + # + class SourceMapper + # Pattern to match LIQUID debug comments + LIQUID_COMMENT_PATTERN = /^(\s*)# LIQUID(?: \| L(\d+))?(?: \| ([^|]+))?(?: \| (.+))?$/ + + # Parse compiled Ruby code and extract source mapping entries + # @param ruby_code [String] The compiled Ruby code with debug comments + # @return [Array] Array of source mapping entries + def self.parse(ruby_code) + entries = [] + ruby_line = 0 + + ruby_code.each_line do |line| + ruby_line += 1 + + if line =~ LIQUID_COMMENT_PATTERN + entries << { + ruby_line: ruby_line, + liquid_line: $2&.to_i, + type: $3&.strip, + source: parse_source_string($4), + } + end + end + + entries + end + + # Find the source location for an error based on Ruby line number + # @param ruby_code [String] The compiled Ruby code + # @param error [Exception] The exception that was raised + # @return [Hash, nil] Source location info or nil if not found + def self.find_source_location(ruby_code, error) + # Extract the line number from the error + ruby_line = extract_error_line(error) + return nil unless ruby_line + + find_source_for_ruby_line(ruby_code, ruby_line) + end + + # Find the source location for a specific Ruby line number + # @param ruby_code [String] The compiled Ruby code + # @param target_line [Integer] The Ruby line number + # @return [Hash, nil] Source location info or nil if not found + def self.find_source_for_ruby_line(ruby_code, target_line) + entries = parse(ruby_code) + + # Find the closest LIQUID comment at or before the target line + closest = nil + entries.each do |entry| + break if entry[:ruby_line] > target_line + closest = entry + end + + closest + end + + # Format an error message with source location info + # @param ruby_code [String] The compiled Ruby code + # @param error [Exception] The exception + # @return [String] Formatted error message + def self.format_error(ruby_code, error) + location = find_source_location(ruby_code, error) + + message = "#{error.class}: #{error.message}" + + if location + liquid_line = location[:liquid_line] ? "line #{location[:liquid_line]}" : "unknown line" + source = location[:source] || location[:type] || "unknown" + message += "\n in Liquid template at #{liquid_line}" + message += "\n source: #{source}" if location[:source] + end + + message + end + + private + + def self.extract_error_line(error) + # Look for (eval):N in backtrace + error.backtrace&.each do |frame| + if frame =~ /\(eval.*?\):(\d+)/ + return $1.to_i + end + end + nil + end + + def self.parse_source_string(str) + return nil unless str + + # Remove surrounding quotes if present + str = str.strip + if str.start_with?('"') && str.end_with?('"') + # Unescape the string + begin + eval(str) + rescue + str[1..-2] + end + else + str + end + end + end + end +end diff --git a/lib/liquid/compile/tags/assign_compiler.rb b/lib/liquid/compile/tags/assign_compiler.rb new file mode 100644 index 00000000..e87d2c45 --- /dev/null +++ b/lib/liquid/compile/tags/assign_compiler.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% assign var = expression %} tags + class AssignCompiler + def self.compile(tag, compiler, code) + var_name = tag.to + # Use VariableCompiler to get the expression with filters applied + value_expr = VariableCompiler.compile_to_expression(tag.from, compiler) + + # Store in the assigns hash + code.line "assigns[#{var_name.inspect}] = #{value_expr}" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/break_compiler.rb b/lib/liquid/compile/tags/break_compiler.rb new file mode 100644 index 00000000..f5813e2b --- /dev/null +++ b/lib/liquid/compile/tags/break_compiler.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% break %} tags + # + # Breaks out of a for loop + class BreakCompiler + def self.compile(_tag, _compiler, code) + # We use throw/catch in the for loop to handle break + # This allows break to work from nested blocks + code.line "throw :__loop__break__" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/capture_compiler.rb b/lib/liquid/compile/tags/capture_compiler.rb new file mode 100644 index 00000000..21520234 --- /dev/null +++ b/lib/liquid/compile/tags/capture_compiler.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% capture var %}...{% endcapture %} tags + # + # Captures the output of the block into a variable + class CaptureCompiler + def self.compile(tag, compiler, code) + var_name = tag.instance_variable_get(:@to) + capture_var = compiler.generate_var_name("capture") + + # Save current output, create new buffer for capture + 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 + end + + # Save captured content and restore output buffer + code.line "assigns[#{var_name.inspect}] = __output__" + code.line "__output__ = #{capture_var}" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/case_compiler.rb b/lib/liquid/compile/tags/case_compiler.rb new file mode 100644 index 00000000..a990b202 --- /dev/null +++ b/lib/liquid/compile/tags/case_compiler.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% case %} / {% when %} / {% else %} / {% endcase %} tags + class CaseCompiler + def self.compile(tag, compiler, code) + # Compile the case expression and store it in a variable + # to avoid evaluating it multiple times + case_var = compiler.generate_var_name("case") + case_expr = ExpressionCompiler.compile(tag.left, compiler) + code.line "#{case_var} = #{case_expr}" + + blocks = tag.blocks + is_first = true + has_else = false + + blocks.each do |block| + if block.else? + has_else = true + code.line "else" + else + # 'when' condition - compare case expression with the when value + when_expr = ExpressionCompiler.compile(block.right, compiler) + + if is_first + code.line "if #{case_var} == #{when_expr}" + is_first = false + else + code.line "elsif #{case_var} == #{when_expr}" + end + end + + code.indent do + if block.attachment + BlockBodyCompiler.compile(block.attachment, compiler, code) + end + end + end + + code.line "end" unless blocks.empty? + end + end + end + end +end diff --git a/lib/liquid/compile/tags/comment_compiler.rb b/lib/liquid/compile/tags/comment_compiler.rb new file mode 100644 index 00000000..43287119 --- /dev/null +++ b/lib/liquid/compile/tags/comment_compiler.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% comment %}...{% endcomment %} tags + # Also handles inline_comment (#) and doc tags + # + # Comments produce no output + class CommentCompiler + def self.compile(_tag, _compiler, code) + # Comments produce no output + code.line "# (liquid comment)" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/continue_compiler.rb b/lib/liquid/compile/tags/continue_compiler.rb new file mode 100644 index 00000000..8061f1b4 --- /dev/null +++ b/lib/liquid/compile/tags/continue_compiler.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% continue %} tags + # + # Skips to the next iteration of a for loop + class ContinueCompiler + def self.compile(_tag, _compiler, code) + # We use throw/catch in the for loop to handle continue + code.line "throw :__loop__continue__" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/cycle_compiler.rb b/lib/liquid/compile/tags/cycle_compiler.rb new file mode 100644 index 00000000..17ca8193 --- /dev/null +++ b/lib/liquid/compile/tags/cycle_compiler.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% cycle 'a', 'b', 'c' %} tags + # + # Cycles through a list of values, outputting the next one each iteration + class CycleCompiler + def self.compile(tag, compiler, code) + variables = tag.variables + name_expr = ExpressionCompiler.compile(tag.instance_variable_get(:@name), compiler) + is_named = tag.named? + + cycle_var = compiler.generate_var_name("cycle") + key_var = compiler.generate_var_name("cycle_key") + + # Initialize cycle storage if needed + code.line "assigns[:__cycle__] ||= {}" + + # Get the cycle key + if is_named + code.line "#{key_var} = #{name_expr}" + else + code.line "#{key_var} = #{variables.object_id}" + end + + # Get current index + code.line "#{cycle_var} = assigns[:__cycle__][#{key_var}].to_i" + + # Get the value at current index + code.line "case #{cycle_var} % #{variables.size}" + variables.each_with_index do |var, idx| + var_expr = ExpressionCompiler.compile(var, compiler) + code.line "when #{idx} then __output__ << __to_s__(#{var_expr})" + end + code.line "end" + + # Increment counter + code.line "assigns[:__cycle__][#{key_var}] = #{cycle_var} + 1" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/decrement_compiler.rb b/lib/liquid/compile/tags/decrement_compiler.rb new file mode 100644 index 00000000..fd3f6396 --- /dev/null +++ b/lib/liquid/compile/tags/decrement_compiler.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% decrement var %} tags + # + # Decrements a counter and outputs its value. + # Uses a separate namespace from regular assigns (shares with increment). + class DecrementCompiler + def self.compile(tag, compiler, code) + var_name = tag.variable_name + + # Initialize counter storage if needed + code.line "assigns[:__counters__] ||= {}" + + # Get current value (default 0), decrement it, output, then store + dec_var = compiler.generate_var_name("dec") + code.line "#{dec_var} = (assigns[:__counters__][#{var_name.inspect}] || 0) - 1" + code.line "assigns[:__counters__][#{var_name.inspect}] = #{dec_var}" + code.line "__output__ << #{dec_var}.to_s" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/echo_compiler.rb b/lib/liquid/compile/tags/echo_compiler.rb new file mode 100644 index 00000000..48dc6472 --- /dev/null +++ b/lib/liquid/compile/tags/echo_compiler.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% echo expression %} tags + # + # Same as {{ expression }} but usable in {% liquid %} blocks + class EchoCompiler + def self.compile(tag, compiler, code) + variable = tag.variable + VariableCompiler.compile(variable, compiler, code) + end + end + end + end +end diff --git a/lib/liquid/compile/tags/for_compiler.rb b/lib/liquid/compile/tags/for_compiler.rb new file mode 100644 index 00000000..ebcd2a87 --- /dev/null +++ b/lib/liquid/compile/tags/for_compiler.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% for %} / {% else %} / {% endfor %} tags + # + # Supports: + # - Iteration: {% for item in collection %} + # - Limit/Offset: {% for item in collection limit:3 offset:2 %} + # - Reversed: {% for item in collection reversed %} + # - Forloop object: forloop.index, forloop.first, forloop.last, etc. + # - Else block: {% for item in collection %}...{% else %}empty{% endfor %} + class ForCompiler + def self.compile(tag, compiler, code) + var_name = tag.variable_name + collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler) + + # Generate unique variable names for this loop + coll_var = compiler.generate_var_name("coll") + idx_var = compiler.generate_var_name("idx") + len_var = compiler.generate_var_name("len") + + # 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 + if tag.from || tag.limit + compile_slice(tag, coll_var, compiler, code) + end + + # Handle reversed + if tag.instance_variable_get(:@reversed) + code.line "#{coll_var} = #{coll_var}.reverse" + end + + # Check if collection is empty for else block + for_block = tag.instance_variable_get(:@for_block) + else_block = tag.instance_variable_get(:@else_block) + + if else_block + code.line "if #{coll_var}.nil? || (#{coll_var}.respond_to?(:empty?) && #{coll_var}.empty?)" + code.indent do + BlockBodyCompiler.compile(else_block, compiler, code) + end + code.line "else" + code.indent do + compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code) + end + code.line "end" + else + compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code) + end + end + + private + + def self.compile_slice(tag, coll_var, compiler, code) + from_expr = if tag.from == :continue + # Continue from previous offset - we'd need register tracking for this + # For now, default to 0 + "0" + elsif tag.from + ExpressionCompiler.compile(tag.from, compiler) + else + "0" + end + + if tag.limit + limit_expr = ExpressionCompiler.compile(tag.limit, compiler) + code.line "#{coll_var} = (#{coll_var}.respond_to?(:slice) ? #{coll_var}.slice(__to_integer__(#{from_expr}), __to_integer__(#{limit_expr})) : #{coll_var}) || []" + else + code.line "#{coll_var} = (#{coll_var}.respond_to?(:drop) ? #{coll_var}.drop(__to_integer__(#{from_expr})) : #{coll_var}) || []" + end + end + + def self.compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code) + # Calculate length for forloop + code.line "#{len_var} = #{coll_var}.respond_to?(:length) ? #{coll_var}.length : 0" + code.line "#{idx_var} = 0" + + # The loop itself - use catch/throw for break support across nested blocks + code.line "catch(:__loop__break__) do" + code.indent do + code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|" + code.indent do + # Wrap each iteration in a catch for continue support + code.line "catch(:__loop__continue__) do" + code.indent do + # Set the loop variable + code.line "assigns[#{var_name.inspect}] = __item__" + + # Build the forloop object as a hash + code.line "assigns['forloop'] = {" + code.indent do + code.line "'name' => #{tag.instance_variable_get(:@name).inspect}," + code.line "'length' => #{len_var}," + code.line "'index' => #{idx_var} + 1," + code.line "'index0' => #{idx_var}," + code.line "'rindex' => #{len_var} - #{idx_var}," + code.line "'rindex0' => #{len_var} - #{idx_var} - 1," + code.line "'first' => #{idx_var} == 0," + code.line "'last' => #{idx_var} == #{len_var} - 1," + end + code.line "}" + + # Compile the loop body + BlockBodyCompiler.compile(for_block, compiler, code) + end + code.line "end" + + # Increment index (runs even after continue) + code.line "#{idx_var} += 1" + end + code.line "end" + end + code.line "end" + + # Clean up + code.line "assigns.delete(#{var_name.inspect})" + code.line "assigns.delete('forloop')" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/if_compiler.rb b/lib/liquid/compile/tags/if_compiler.rb new file mode 100644 index 00000000..11f39979 --- /dev/null +++ b/lib/liquid/compile/tags/if_compiler.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% if %} / {% elsif %} / {% else %} / {% endif %} tags + class IfCompiler + def self.compile(tag, compiler, code) + blocks = tag.blocks + + blocks.each_with_index do |block, index| + condition_expr = ConditionCompiler.compile(block, compiler) + + if index == 0 + code.line "if #{condition_expr}" + elsif block.else? + code.line "else" + else + code.line "elsif #{condition_expr}" + end + + code.indent do + if block.attachment + BlockBodyCompiler.compile(block.attachment, compiler, code) + end + end + end + + code.line "end" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/ifchanged_compiler.rb b/lib/liquid/compile/tags/ifchanged_compiler.rb new file mode 100644 index 00000000..97ddc34a --- /dev/null +++ b/lib/liquid/compile/tags/ifchanged_compiler.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% ifchanged %}...{% endifchanged %} tags + # + # Only outputs if the content has changed since last render + class IfchangedCompiler + def self.compile(tag, compiler, code) + capture_var = compiler.generate_var_name("ifchanged") + + # Capture the block output + code.line "#{capture_var} = +''" + code.line "begin" + code.indent do + code.line "__saved_output__ = __output__" + code.line "__output__ = #{capture_var}" + + # Compile the body + tag.nodelist.each do |body| + BlockBodyCompiler.compile(body, compiler, code) + end + + code.line "__output__ = __saved_output__" + end + code.line "end" + + # Only output if changed + code.line "if #{capture_var} != assigns[:__ifchanged__]" + code.indent do + code.line "assigns[:__ifchanged__] = #{capture_var}" + code.line "__output__ << #{capture_var}" + end + code.line "end" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/include_compiler.rb b/lib/liquid/compile/tags/include_compiler.rb new file mode 100644 index 00000000..ea71d631 --- /dev/null +++ b/lib/liquid/compile/tags/include_compiler.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% include 'partial' %} tags + # + # Include is deprecated in favor of render, but we support it for compatibility. + # Unlike render, include shares the outer scope with the partial. + # + # For static template names, the partial is loaded and inlined at compile time. + # For dynamic template names, a runtime fallback is generated. + class IncludeCompiler + def self.compile(tag, compiler, code) + template_name_expr = tag.template_name_expr + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + alias_name = tag.instance_variable_get(:@alias_name) + + # Check if the template name is a static string + if template_name_expr.is_a?(String) + compile_static_include(tag, template_name_expr, compiler, code) + else + compile_dynamic_include(tag, compiler, code) + end + end + + private + + def self.compile_static_include(tag, template_name, compiler, code) + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + alias_name = tag.instance_variable_get(:@alias_name) + + # Try to load the partial at compile time + partial_source = compiler.load_partial(template_name) + + if partial_source + # 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 + + # Include shares scope, so we just set variables directly + code.line "# Include: #{template_name}" + + # Set attributes + attributes.each do |key, value| + value_expr = ExpressionCompiler.compile(value, compiler) + code.line "assigns[#{key.inspect}] = #{value_expr}" + end + + # Set the context variable + if variable_name_expr + var_expr = ExpressionCompiler.compile(variable_name_expr, compiler) + var_var = compiler.generate_var_name("incvar") + code.line "#{var_var} = #{var_expr}" + + # If it's an array, loop through it + code.line "if #{var_var}.is_a?(Array)" + code.indent do + code.line "#{var_var}.each do |__include_item__|" + code.indent do + code.line "assigns[#{context_var_name.inspect}] = __include_item__" + code.line "__output__ << #{method_name}({})" + end + code.line "end" + end + code.line "else" + code.indent do + code.line "assigns[#{context_var_name.inspect}] = #{var_var}" + code.line "__output__ << #{method_name}({})" + end + code.line "end" + else + # Try to find a variable with the same name as the template + code.line "assigns[#{context_var_name.inspect}] = assigns[#{template_name.inspect}] if assigns.key?(#{template_name.inspect})" + code.line "__output__ << #{method_name}({})" + end + else + # Partial not found at compile time + code.line "# Partial '#{template_name}' not found at compile time" + compile_dynamic_include(tag, compiler, code) + end + end + + def self.compile_dynamic_include(tag, compiler, code) + template_name_expr = tag.template_name_expr + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + alias_name = tag.instance_variable_get(:@alias_name) + + name_expr = ExpressionCompiler.compile(template_name_expr, compiler) + + # Build attributes hash + attrs_var = compiler.generate_var_name("attrs") + code.line "#{attrs_var} = {}" + attributes.each do |key, value| + value_expr = ExpressionCompiler.compile(value, compiler) + code.line "#{attrs_var}[#{key.inspect}] = #{value_expr}" + end + + var_expr = variable_name_expr ? ExpressionCompiler.compile(variable_name_expr, compiler) : "nil" + alias_expr = alias_name ? alias_name.inspect : "nil" + + # Call the runtime dynamic include method + code.line "if defined?(__include_dynamic__)" + code.indent do + code.line "__output__ << __include_dynamic__(#{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, assigns)" + end + code.line "else" + code.indent do + code.line "raise RuntimeError, 'Dynamic include requires __include_dynamic__ method: ' + #{name_expr}.inspect" + end + code.line "end" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/increment_compiler.rb b/lib/liquid/compile/tags/increment_compiler.rb new file mode 100644 index 00000000..e179669a --- /dev/null +++ b/lib/liquid/compile/tags/increment_compiler.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% increment var %} tags + # + # Outputs the current counter value, then increments it. + # Uses a separate namespace from regular assigns (shares with decrement). + class IncrementCompiler + def self.compile(tag, compiler, code) + var_name = tag.variable_name + + # Initialize counter storage if needed + code.line "assigns[:__counters__] ||= {}" + + # Get current value (default 0), output it, then increment + inc_var = compiler.generate_var_name("inc") + code.line "#{inc_var} = assigns[:__counters__][#{var_name.inspect}] || 0" + code.line "__output__ << #{inc_var}.to_s" + code.line "assigns[:__counters__][#{var_name.inspect}] = #{inc_var} + 1" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/raw_compiler.rb b/lib/liquid/compile/tags/raw_compiler.rb new file mode 100644 index 00000000..5375b394 --- /dev/null +++ b/lib/liquid/compile/tags/raw_compiler.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% raw %}...{% endraw %} tags + # + # Outputs the content as-is without parsing Liquid syntax + class RawCompiler + def self.compile(tag, compiler, code) + body = tag.instance_variable_get(:@body) + return if body.nil? || body.empty? + + code.line "__output__ << #{body.inspect}" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/render_compiler.rb b/lib/liquid/compile/tags/render_compiler.rb new file mode 100644 index 00000000..e59659f3 --- /dev/null +++ b/lib/liquid/compile/tags/render_compiler.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% render 'partial' %} tags + # + # For static template names (string literals), the partial is loaded at + # compile time and inlined as a method. + # + # For dynamic template names (variables), a runtime fallback is generated + # that calls __render_dynamic__ which must be provided by the runtime. + class RenderCompiler + def self.compile(tag, compiler, code) + template_name_expr = tag.template_name_expr + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + alias_name = tag.alias_name + is_for_loop = tag.for_loop? + + # Check if the template name is a static string + if template_name_expr.is_a?(String) + compile_static_render(tag, template_name_expr, compiler, code) + else + compile_dynamic_render(tag, compiler, code) + end + end + + private + + def self.compile_static_render(tag, template_name, compiler, code) + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + alias_name = tag.alias_name + is_for_loop = tag.for_loop? + + # Try to load the partial at compile time + partial_source = compiler.load_partial(template_name) + + if partial_source + # 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 + + if is_for_loop && variable_name_expr + # Render for each item in collection + compile_for_loop_render(tag, method_name, context_var_name, compiler, code) + else + # Single render + compile_single_render(tag, method_name, context_var_name, compiler, code) + end + else + # Partial not found at compile time - generate runtime fallback + code.line "# Partial '#{template_name}' not found at compile time" + compile_dynamic_render(tag, compiler, code) + end + end + + def self.compile_single_render(tag, method_name, context_var_name, compiler, code) + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + + # Build the inner assigns hash + inner_assigns_var = compiler.generate_var_name("inner") + code.line "#{inner_assigns_var} = {}" + + # Copy attributes + attributes.each do |key, value| + value_expr = ExpressionCompiler.compile(value, compiler) + code.line "#{inner_assigns_var}[#{key.inspect}] = #{value_expr}" + end + + # Set the context variable if provided + if variable_name_expr + var_expr = ExpressionCompiler.compile(variable_name_expr, compiler) + code.line "#{inner_assigns_var}[#{context_var_name.inspect}] = #{var_expr}" + end + + # Call the partial method + code.line "__output__ << #{method_name}(#{inner_assigns_var})" + end + + def self.compile_for_loop_render(tag, method_name, context_var_name, compiler, code) + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + template_name = tag.template_name_expr + + coll_var = compiler.generate_var_name("coll") + coll_expr = ExpressionCompiler.compile(variable_name_expr, compiler) + code.line "#{coll_var} = #{coll_expr}" + + code.line "if #{coll_var}.respond_to?(:each) && #{coll_var}.respond_to?(:count)" + code.indent do + len_var = compiler.generate_var_name("len") + idx_var = compiler.generate_var_name("idx") + code.line "#{len_var} = #{coll_var}.count" + code.line "#{idx_var} = 0" + + code.line "#{coll_var}.each do |__item__|" + code.indent do + inner_assigns_var = compiler.generate_var_name("inner") + code.line "#{inner_assigns_var} = {}" + + # Copy attributes + attributes.each do |key, value| + value_expr = ExpressionCompiler.compile(value, compiler) + code.line "#{inner_assigns_var}[#{key.inspect}] = #{value_expr}" + end + + # Set the context variable + code.line "#{inner_assigns_var}[#{context_var_name.inspect}] = __item__" + + # Set forloop + code.line "#{inner_assigns_var}['forloop'] = {" + code.indent do + code.line "'name' => #{template_name.inspect}," + code.line "'length' => #{len_var}," + code.line "'index' => #{idx_var} + 1," + code.line "'index0' => #{idx_var}," + code.line "'rindex' => #{len_var} - #{idx_var}," + code.line "'rindex0' => #{len_var} - #{idx_var} - 1," + code.line "'first' => #{idx_var} == 0," + code.line "'last' => #{idx_var} == #{len_var} - 1," + end + code.line "}" + + # Call the partial method + code.line "__output__ << #{method_name}(#{inner_assigns_var})" + + code.line "#{idx_var} += 1" + end + code.line "end" + end + code.line "else" + code.indent do + # Single render if not a collection + compile_single_render(tag, method_name, context_var_name, compiler, code) + end + code.line "end" + end + + def self.compile_dynamic_render(tag, compiler, code) + template_name_expr = tag.template_name_expr + variable_name_expr = tag.variable_name_expr + attributes = tag.attributes + alias_name = tag.alias_name + is_for_loop = tag.for_loop? + + name_expr = ExpressionCompiler.compile(template_name_expr, compiler) + + # Build attributes hash + attrs_var = compiler.generate_var_name("attrs") + code.line "#{attrs_var} = {}" + attributes.each do |key, value| + value_expr = ExpressionCompiler.compile(value, compiler) + code.line "#{attrs_var}[#{key.inspect}] = #{value_expr}" + end + + var_expr = variable_name_expr ? ExpressionCompiler.compile(variable_name_expr, compiler) : "nil" + alias_expr = alias_name ? alias_name.inspect : "nil" + + # Call the runtime dynamic render method + code.line "if defined?(__render_dynamic__)" + code.indent do + code.line "__output__ << __render_dynamic__(#{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, #{is_for_loop})" + end + code.line "else" + code.indent do + code.line "raise RuntimeError, 'Dynamic render requires __render_dynamic__ method: ' + #{name_expr}.inspect" + end + code.line "end" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/tablerow_compiler.rb b/lib/liquid/compile/tags/tablerow_compiler.rb new file mode 100644 index 00000000..41db922a --- /dev/null +++ b/lib/liquid/compile/tags/tablerow_compiler.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% tablerow %} tags + # + # Creates HTML table rows from a collection + class TableRowCompiler + def self.compile(tag, compiler, code) + var_name = tag.variable_name + collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler) + + # Generate unique variable names + coll_var = compiler.generate_var_name("coll") + idx_var = compiler.generate_var_name("idx") + len_var = compiler.generate_var_name("len") + cols_var = compiler.generate_var_name("cols") + row_var = compiler.generate_var_name("row") + col_var = compiler.generate_var_name("col") + + # Get the columns parameter + cols = tag.instance_variable_get(:@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) + if offset || limit + if offset + offset_expr = ExpressionCompiler.compile(offset, compiler) + if limit + limit_expr = ExpressionCompiler.compile(limit, compiler) + code.line "#{coll_var} = #{coll_var}.slice(__to_integer__(#{offset_expr}), __to_integer__(#{limit_expr})) || []" + else + code.line "#{coll_var} = #{coll_var}.drop(__to_integer__(#{offset_expr}))" + end + elsif limit + limit_expr = ExpressionCompiler.compile(limit, compiler) + code.line "#{coll_var} = #{coll_var}.first(__to_integer__(#{limit_expr}))" + end + end + + # Setup loop variables + code.line "#{len_var} = #{coll_var}.respond_to?(:length) ? #{coll_var}.length : 0" + code.line "#{cols_var} = #{cols_expr} || #{len_var}" + code.line "#{idx_var} = 0" + code.line "#{row_var} = 1" + code.line "#{col_var} = 0" + + body = tag.instance_variable_get(:@body) + + # 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__ << \"\"" + end + code.line "end" + code.line "#{col_var} += 1" + + # Output cell start + code.line "__output__ << \"\"" + + # Set loop variables + code.line "assigns[#{var_name.inspect}] = __item__" + code.line "assigns['tablerowloop'] = {" + code.indent do + code.line "'length' => #{len_var}," + code.line "'index' => #{idx_var} + 1," + code.line "'index0' => #{idx_var}," + code.line "'rindex' => #{len_var} - #{idx_var}," + code.line "'rindex0' => #{len_var} - #{idx_var} - 1," + code.line "'first' => #{idx_var} == 0," + code.line "'last' => #{idx_var} == #{len_var} - 1," + code.line "'col' => #{col_var}," + code.line "'col0' => #{col_var} - 1," + code.line "'row' => #{row_var}," + code.line "'col_first' => #{col_var} == 1," + code.line "'col_last' => #{col_var} == #{cols_var}," + end + code.line "}" + + # Compile the body + BlockBodyCompiler.compile(body, compiler, code) + + # Output cell end + code.line "__output__ << ''" + + # End row if needed + code.line "if #{col_var} == #{cols_var}" + code.indent do + code.line "__output__ << ''" + code.line "#{col_var} = 0" + code.line "#{row_var} += 1" + end + code.line "end" + + code.line "#{idx_var} += 1" + end + code.line "end" + + # Close any open row + code.line "if #{col_var} > 0" + code.indent do + code.line "__output__ << ''" + end + code.line "end" + + # Clean up + code.line "assigns.delete(#{var_name.inspect})" + code.line "assigns.delete('tablerowloop')" + end + end + end + end +end diff --git a/lib/liquid/compile/tags/unless_compiler.rb b/lib/liquid/compile/tags/unless_compiler.rb new file mode 100644 index 00000000..e171bb1d --- /dev/null +++ b/lib/liquid/compile/tags/unless_compiler.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Liquid + module Compile + module Tags + # Compiles {% unless %} / {% elsif %} / {% else %} / {% endunless %} tags + # + # Unless is like if, but the first condition is negated + class UnlessCompiler + def self.compile(tag, compiler, code) + blocks = tag.blocks + + blocks.each_with_index do |block, index| + condition_expr = ConditionCompiler.compile(block, compiler) + + if index == 0 + # First block is negated (unless = if not) + code.line "unless #{condition_expr}" + elsif block.else? + code.line "else" + else + # Subsequent blocks (elsif) are normal + code.line "elsif #{condition_expr}" + end + + code.indent do + if block.attachment + BlockBodyCompiler.compile(block.attachment, compiler, code) + end + end + end + + code.line "end" + end + end + end + end +end diff --git a/lib/liquid/compile/variable_compiler.rb b/lib/liquid/compile/variable_compiler.rb new file mode 100644 index 00000000..3ad78efb --- /dev/null +++ b/lib/liquid/compile/variable_compiler.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Liquid + module Compile + # VariableCompiler compiles Liquid variable expressions ({{ ... }}) to Ruby code. + # + # A Variable consists of: + # - A name expression (the value to output) + # - Zero or more filters to apply + class VariableCompiler + # Compile a Variable node and append the result to the output buffer + # @param variable [Liquid::Variable] The variable node + # @param compiler [RubyCompiler] The main compiler instance + # @param code [CodeGenerator] The code generator + def self.compile(variable, compiler, code) + value_expr = compile_to_expression(variable, compiler) + code.line "__output__ << __output_value__(#{value_expr})" + end + + # Compile a Variable node to a Ruby expression (without output) + # @param variable [Liquid::Variable] The variable node + # @param compiler [RubyCompiler] The main compiler instance + # @return [String] Ruby code expression + def self.compile_to_expression(variable, compiler) + # Compile the base name expression + base_expr = ExpressionCompiler.compile(variable.name, compiler) + + # Apply filters if any + if variable.filters && !variable.filters.empty? + FilterCompiler.compile(base_expr, variable.filters, compiler) + else + base_expr + end + end + end + end +end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 9fb4089a..121219a0 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -205,6 +205,51 @@ module Liquid render(context, output: output) end + # Compile the template to pure Ruby code. + # + # Returns a string containing Ruby code that can be eval'd to create + # a proc/lambda. The proc takes an assigns hash and returns the rendered + # output string. + # + # This provides a way to convert Liquid templates to standalone Ruby code + # that can be executed without the Liquid library at runtime. + # + # == Example + # + # template = Liquid::Template.parse("Hello, {{ name }}!") + # ruby_code = template.compile_to_ruby + # render_proc = eval(ruby_code) + # result = render_proc.call({ "name" => "World" }) + # # => "Hello, World!" + # + # == Options + # + # * :strict_variables - Raise on undefined variables (default: false) + # * :include_filters - Include helper methods for filters (default: true) + # + # == Advantages of Compiled Code + # + # * No Context object overhead + # * No filter invocation overhead (direct method calls) + # * No resource limits tracking + # * No stack-based scoping (uses Ruby's native scoping) + # * No profiling hooks + # * Direct string concatenation + # + # == Limitations + # + # * {% render %} and {% include %} tags require runtime support + # * Custom tags need explicit compiler implementations + # * Custom filters must be available at runtime + # + def compile_to_ruby(options = {}) + return nil if @root.nil? + + require 'liquid/compile' + compiler = Compile::RubyCompiler.new(self, options) + compiler.compile + end + private def configure_options(options) diff --git a/test/unit/compile_test.rb b/test/unit/compile_test.rb new file mode 100644 index 00000000..ed0435a7 --- /dev/null +++ b/test/unit/compile_test.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require 'test_helper' + +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({}) + 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" }) + 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" }) + 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" }) + 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 }) + 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 }) + 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 }) + 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 }) + 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"] }) + 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"] }) + 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" => [] }) + 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"] }) + 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({}) + 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" }) + 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" }) + 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 }) + 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({}) + 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({}) + 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({}) + 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({}) + 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({}) + end + + def test_compile_nested_property_access + template = Template.parse("{{ user.profile.name }}") + ruby_code = template.compile_to_ruby + render_proc = eval(ruby_code) + data = { "user" => { "profile" => { "name" => "Alice" } } } + assert_equal "Alice", render_proc.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"] }) + 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] }) + 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"] }) + 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" }) + 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 }) + 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({}) + 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"] }) + 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" => "

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" }) + 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" }) + 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({}) + 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({}) + 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 }) + 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 }) + 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" }) + end + + def test_compile_produces_valid_ruby + template = Template.parse("{% for item in items %}{{ item | upcase }}{% endfor %}") + ruby_code = template.compile_to_ruby + + # Should produce valid Ruby syntax + assert_nothing_raised do + eval(ruby_code) + end + end + + def test_compile_vs_render_equivalence + templates = [ + "Hello, {{ name }}!", + "{% if show %}visible{% else %}hidden{% endif %}", + "{% for i in (1..3) %}{{ i }}{% endfor %}", + "{{ str | upcase | split: '' | join: '-' }}", + "{% assign x = 5 %}{% assign y = x | plus: 3 %}{{ y }}", + ] + + assigns_list = [ + { "name" => "World", "show" => true, "str" => "hello" }, + { "name" => "Ruby", "show" => false, "str" => "test" }, + ] + + templates.each do |source| + template = Template.parse(source) + ruby_code = template.compile_to_ruby + render_proc = eval(ruby_code) + + assigns_list.each do |assigns| + expected = template.render(assigns) + actual = render_proc.call(assigns.dup) + assert_equal expected, actual, "Mismatch for template '#{source}' with assigns #{assigns}" + end + end + end +end