mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-15 08:50:45 -07:00
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
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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{<script.*?</script>|<!--.*?-->|<style.*?</style>}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/, \"<br />\\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
|
||||
@@ -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<line> | <description> | <source>"
|
||||
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
|
||||
@@ -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<Hash>] 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__ << \"<tr class=\\\"row\#{#{row_var}}\\\">\""
|
||||
end
|
||||
code.line "end"
|
||||
code.line "#{col_var} += 1"
|
||||
|
||||
# Output cell start
|
||||
code.line "__output__ << \"<td class=\\\"col\#{#{col_var}}\\\">\""
|
||||
|
||||
# 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__ << '</td>'"
|
||||
|
||||
# End row if needed
|
||||
code.line "if #{col_var} == #{cols_var}"
|
||||
code.indent do
|
||||
code.line "__output__ << '</tr>'"
|
||||
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__ << '</tr>'"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Clean up
|
||||
code.line "assigns.delete(#{var_name.inspect})"
|
||||
code.line "assigns.delete('tablerowloop')"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
#
|
||||
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
|
||||
# * <tt>:include_filters</tt> - 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)
|
||||
|
||||
Reference in New Issue
Block a user