Skip to content

Commit e0f856f

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

29 files changed

+2586
-0
lines changed

lib/liquid/compile.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
# Liquid Ruby Compiler
4+
#
5+
# This module provides the ability to compile Liquid templates to pure Ruby code.
6+
# The compiled code can be eval'd to create a proc that renders the template
7+
# without needing the Liquid library at runtime.
8+
#
9+
# ## Usage
10+
#
11+
# template = Liquid::Template.parse("Hello, {{ name }}!")
12+
# ruby_code = template.compile_to_ruby
13+
# render_proc = eval(ruby_code)
14+
# result = render_proc.call({ "name" => "World" })
15+
# # => "Hello, World!"
16+
#
17+
# ## Optimization Opportunities
18+
#
19+
# The compiled Ruby code has several significant advantages over interpreted Liquid:
20+
#
21+
# 1. **No Context Object**: Variables are extracted directly from the assigns hash
22+
# and accessed without the Context abstraction layer.
23+
#
24+
# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby method
25+
# calls rather than going through context.invoke().
26+
#
27+
# 3. **No Resource Limits Tracking**: The compiled code doesn't track render
28+
# scores, write scores, or assign scores, eliminating per-node overhead.
29+
#
30+
# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead
31+
# of manually managing scope stacks.
32+
#
33+
# 5. **Direct String Concatenation**: Output is built with direct << operations.
34+
#
35+
# 6. **Native Control Flow**: break/continue use Ruby's throw/catch mechanism.
36+
#
37+
# 7. **No to_liquid Calls**: Values are used directly without conversion.
38+
#
39+
# 8. **No Profiling Hooks**: No profiler overhead in the generated code.
40+
#
41+
# 9. **No Exception Rendering**: Errors propagate naturally.
42+
#
43+
# ## Limitations
44+
#
45+
# - {% render %} and {% include %} tags require runtime support
46+
# - Custom tags need explicit compiler implementations
47+
# - Custom filters need to be available at runtime
48+
#
49+
module Liquid
50+
module Compile
51+
autoload :CodeGenerator, 'liquid/compile/code_generator'
52+
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
53+
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
54+
autoload :FilterCompiler, 'liquid/compile/filter_compiler'
55+
autoload :VariableCompiler, 'liquid/compile/variable_compiler'
56+
autoload :BlockBodyCompiler, 'liquid/compile/block_body_compiler'
57+
autoload :ConditionCompiler, 'liquid/compile/condition_compiler'
58+
autoload :SourceMapper, 'liquid/compile/source_mapper'
59+
60+
module Tags
61+
autoload :IfCompiler, 'liquid/compile/tags/if_compiler'
62+
autoload :UnlessCompiler, 'liquid/compile/tags/unless_compiler'
63+
autoload :CaseCompiler, 'liquid/compile/tags/case_compiler'
64+
autoload :ForCompiler, 'liquid/compile/tags/for_compiler'
65+
autoload :AssignCompiler, 'liquid/compile/tags/assign_compiler'
66+
autoload :CaptureCompiler, 'liquid/compile/tags/capture_compiler'
67+
autoload :CycleCompiler, 'liquid/compile/tags/cycle_compiler'
68+
autoload :IncrementCompiler, 'liquid/compile/tags/increment_compiler'
69+
autoload :DecrementCompiler, 'liquid/compile/tags/decrement_compiler'
70+
autoload :RawCompiler, 'liquid/compile/tags/raw_compiler'
71+
autoload :EchoCompiler, 'liquid/compile/tags/echo_compiler'
72+
autoload :BreakCompiler, 'liquid/compile/tags/break_compiler'
73+
autoload :ContinueCompiler, 'liquid/compile/tags/continue_compiler'
74+
autoload :CommentCompiler, 'liquid/compile/tags/comment_compiler'
75+
autoload :TableRowCompiler, 'liquid/compile/tags/tablerow_compiler'
76+
autoload :RenderCompiler, 'liquid/compile/tags/render_compiler'
77+
autoload :IncludeCompiler, 'liquid/compile/tags/include_compiler'
78+
autoload :IfchangedCompiler, 'liquid/compile/tags/ifchanged_compiler'
79+
end
80+
end
81+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
module Compile
5+
# BlockBodyCompiler compiles a BlockBody (a list of nodes) to Ruby code.
6+
#
7+
# A BlockBody contains:
8+
# - String literals (text to output)
9+
# - Variable expressions ({{ ... }})
10+
# - Tags ({% ... %})
11+
class BlockBodyCompiler
12+
# Compile a BlockBody to Ruby code
13+
# @param body [Liquid::BlockBody] The block body
14+
# @param compiler [RubyCompiler] The main compiler instance
15+
# @param code [CodeGenerator] The code generator
16+
def self.compile(body, compiler, code)
17+
return if body.nil?
18+
19+
nodelist = body.nodelist
20+
return if nodelist.nil? || nodelist.empty?
21+
22+
nodelist.each do |node|
23+
compile_node(node, compiler, code)
24+
end
25+
end
26+
27+
# Compile a single node
28+
def self.compile_node(node, compiler, code)
29+
case node
30+
when String
31+
compile_string(node, code)
32+
when Variable
33+
VariableCompiler.compile(node, compiler, code)
34+
when Tag
35+
compiler.send(:compile_tag, node, code)
36+
else
37+
raise CompileError, "Unknown node type in BlockBody: #{node.class}"
38+
end
39+
end
40+
41+
private
42+
43+
def self.compile_string(str, code)
44+
return if str.empty?
45+
code.line "__output__ << #{str.inspect}"
46+
end
47+
end
48+
end
49+
end
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
module Compile
5+
# CodeGenerator provides a clean interface for building Ruby code strings
6+
# with proper indentation and formatting.
7+
class CodeGenerator
8+
INDENT_SIZE = 2
9+
10+
def initialize
11+
@lines = []
12+
@indent_level = 0
13+
end
14+
15+
# Add a line of code at the current indentation level
16+
# @param text [String] The code to add
17+
def line(text)
18+
@lines << (" " * @indent_level) + text
19+
end
20+
21+
# Add a blank line
22+
def blank_line
23+
@lines << ""
24+
end
25+
26+
# Add multiple lines (useful for multi-line strings)
27+
# @param text [String] Multi-line string to add
28+
def lines(text)
29+
text.each_line do |l|
30+
line(l.chomp)
31+
end
32+
end
33+
34+
# Increase indentation for a block
35+
def indent
36+
@indent_level += 1
37+
yield
38+
@indent_level -= 1
39+
end
40+
41+
# Get the current indentation string
42+
def current_indent
43+
" " * @indent_level
44+
end
45+
46+
# Add raw code without indentation adjustment
47+
def raw(text)
48+
@lines << text
49+
end
50+
51+
# Convert to final Ruby code string
52+
def to_s
53+
@lines.join("\n")
54+
end
55+
56+
# Generate an inline expression (doesn't add to lines, returns string)
57+
# @param expr [String] The expression
58+
# @return [String] The expression wrapped appropriately
59+
def self.inline(expr)
60+
expr
61+
end
62+
63+
# Generate a string literal
64+
# @param str [String] The string to escape
65+
# @return [String] Ruby string literal
66+
def self.string_literal(str)
67+
str.inspect
68+
end
69+
70+
# Generate a safe variable name from a Liquid variable name
71+
# @param name [String] The Liquid variable name
72+
# @return [String] A safe Ruby variable name
73+
def self.safe_var_name(name)
74+
# Replace invalid characters with underscores
75+
safe = name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
76+
# Ensure it starts with a letter or underscore
77+
safe = "_#{safe}" if safe =~ /\A\d/
78+
safe
79+
end
80+
end
81+
end
82+
end
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
module Compile
5+
# ConditionCompiler compiles Liquid conditions to Ruby boolean expressions.
6+
#
7+
# Handles:
8+
# - Simple truthiness: {% if variable %}
9+
# - Comparisons: {% if a == b %}, {% if a > b %}
10+
# - Logical operators: {% if a and b %}, {% if a or b %}
11+
# - Special checks: {% if a == blank %}, {% if a == empty %}
12+
class ConditionCompiler
13+
# Operator mappings from Liquid to Ruby
14+
OPERATORS = {
15+
'==' => '==',
16+
'!=' => '!=',
17+
'<>' => '!=',
18+
'<' => '<',
19+
'>' => '>',
20+
'<=' => '<=',
21+
'>=' => '>=',
22+
'contains' => :contains,
23+
}.freeze
24+
25+
# Compile a Condition to a Ruby boolean expression
26+
# @param condition [Liquid::Condition] The condition
27+
# @param compiler [RubyCompiler] The main compiler instance
28+
# @return [String] Ruby code expression that evaluates to true/false
29+
def self.compile(condition, compiler)
30+
if condition.is_a?(ElseCondition)
31+
return "true"
32+
end
33+
34+
compile_condition_chain(condition, compiler)
35+
end
36+
37+
private
38+
39+
def self.compile_condition_chain(condition, compiler)
40+
# Compile the current condition
41+
current = compile_single_condition(condition, compiler)
42+
43+
# Check for chained conditions (and/or)
44+
if condition.child_condition
45+
child = compile_condition_chain(condition.child_condition, compiler)
46+
child_relation = condition.send(:child_relation)
47+
48+
case child_relation
49+
when :and
50+
"(#{current} && #{child})"
51+
when :or
52+
"(#{current} || #{child})"
53+
else
54+
current
55+
end
56+
else
57+
current
58+
end
59+
end
60+
61+
def self.compile_single_condition(condition, compiler)
62+
left = condition.left
63+
op = condition.operator
64+
right = condition.right
65+
66+
# If no operator, just check truthiness
67+
if op.nil?
68+
left_expr = ExpressionCompiler.compile(left, compiler)
69+
return "__truthy__(#{left_expr})"
70+
end
71+
72+
# Compile left and right expressions
73+
left_expr = compile_condition_value(left, compiler)
74+
right_expr = compile_condition_value(right, compiler)
75+
76+
# Handle special operators
77+
case OPERATORS[op]
78+
when :contains
79+
compile_contains(left_expr, right_expr, compiler)
80+
when '=='
81+
compile_equality(left, right, left_expr, right_expr, compiler)
82+
when '!='
83+
"!(#{compile_equality(left, right, left_expr, right_expr, compiler)})"
84+
else
85+
# Standard comparison
86+
ruby_op = OPERATORS[op] || op
87+
"(#{left_expr} #{ruby_op} #{right_expr} rescue false)"
88+
end
89+
end
90+
91+
def self.compile_condition_value(expr, compiler)
92+
if expr.is_a?(Condition::MethodLiteral)
93+
# For blank/empty checks, we return a special marker
94+
# The equality handler will deal with this
95+
":__method_literal_#{expr.method_name}__"
96+
else
97+
ExpressionCompiler.compile(expr, compiler)
98+
end
99+
end
100+
101+
def self.compile_equality(left, right, left_expr, right_expr, compiler)
102+
# Handle blank/empty method literals
103+
if left.is_a?(Condition::MethodLiteral)
104+
method_name = left.method_name
105+
"(#{right_expr}.respond_to?(:#{method_name}) ? #{right_expr}.#{method_name} : nil)"
106+
elsif right.is_a?(Condition::MethodLiteral)
107+
method_name = right.method_name
108+
"(#{left_expr}.respond_to?(:#{method_name}) ? #{left_expr}.#{method_name} : nil)"
109+
else
110+
"(#{left_expr} == #{right_expr})"
111+
end
112+
end
113+
114+
def self.compile_contains(left_expr, right_expr, compiler)
115+
# The contains operator checks if left includes right
116+
# For strings, right is converted to a string
117+
"(lambda { |left, right| " \
118+
"return false if left.nil? || right.nil? || !left.respond_to?(:include?); " \
119+
"right = right.to_s if left.is_a?(String); " \
120+
"left.include?(right) rescue false " \
121+
"}.call(#{left_expr}, #{right_expr}))"
122+
end
123+
end
124+
end
125+
end

0 commit comments

Comments
 (0)