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:
Claude
2025-12-31 14:21:58 +00:00
parent c6f05eaf83
commit e0f856fd11
29 changed files with 2586 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
# frozen_string_literal: true
require 'test_helper'
class CompileTest < Minitest::Test
include Liquid
def test_compile_simple_string
template = Template.parse("Hello, World!")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "Hello, World!", render_proc.call({})
end
def test_compile_variable
template = Template.parse("Hello, {{ name }}!")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "Hello, World!", render_proc.call({ "name" => "World" })
end
def test_compile_variable_with_filter
template = Template.parse("{{ name | upcase }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "WORLD", render_proc.call({ "name" => "world" })
end
def test_compile_variable_with_multiple_filters
template = Template.parse("{{ name | downcase | capitalize }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "Hello", render_proc.call({ "name" => "HELLO" })
end
def test_compile_if_true
template = Template.parse("{% if show %}visible{% endif %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "visible", render_proc.call({ "show" => true })
assert_equal "", render_proc.call({ "show" => false })
end
def test_compile_if_else
template = Template.parse("{% if show %}yes{% else %}no{% endif %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "yes", render_proc.call({ "show" => true })
assert_equal "no", render_proc.call({ "show" => false })
end
def test_compile_if_elsif
template = Template.parse("{% if x == 1 %}one{% elsif x == 2 %}two{% else %}other{% endif %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "one", render_proc.call({ "x" => 1 })
assert_equal "two", render_proc.call({ "x" => 2 })
assert_equal "other", render_proc.call({ "x" => 3 })
end
def test_compile_unless
template = Template.parse("{% unless hidden %}visible{% endunless %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "visible", render_proc.call({ "hidden" => false })
assert_equal "", render_proc.call({ "hidden" => true })
end
def test_compile_for_loop
template = Template.parse("{% for item in items %}{{ item }} {% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "a b c ", render_proc.call({ "items" => ["a", "b", "c"] })
end
def test_compile_for_loop_with_forloop
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "1:a 2:b 3:c ", render_proc.call({ "items" => ["a", "b", "c"] })
end
def test_compile_for_loop_else
template = Template.parse("{% for item in items %}{{ item }}{% else %}empty{% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "abc", render_proc.call({ "items" => ["a", "b", "c"] })
assert_equal "empty", render_proc.call({ "items" => [] })
end
def test_compile_for_with_limit
template = Template.parse("{% for item in items limit:2 %}{{ item }}{% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "ab", render_proc.call({ "items" => ["a", "b", "c", "d"] })
end
def test_compile_assign
template = Template.parse("{% assign x = 'hello' %}{{ x }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "hello", render_proc.call({})
end
def test_compile_assign_with_filter
template = Template.parse("{% assign x = name | upcase %}{{ x }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "WORLD", render_proc.call({ "name" => "world" })
end
def test_compile_capture
template = Template.parse("{% capture greeting %}Hello, {{ name }}!{% endcapture %}{{ greeting }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "Hello, World!", render_proc.call({ "name" => "World" })
end
def test_compile_case
template = Template.parse("{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "one", render_proc.call({ "x" => 1 })
assert_equal "two", render_proc.call({ "x" => 2 })
assert_equal "other", render_proc.call({ "x" => 3 })
end
def test_compile_raw
template = Template.parse("{% raw %}{{ not_a_variable }}{% endraw %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "{{ not_a_variable }}", render_proc.call({})
end
def test_compile_comment
template = Template.parse("before{% comment %}hidden{% endcomment %}after")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "beforeafter", render_proc.call({})
end
def test_compile_increment
template = Template.parse("{% increment x %}{% increment x %}{% increment x %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "012", render_proc.call({})
end
def test_compile_decrement
template = Template.parse("{% decrement x %}{% decrement x %}{% decrement x %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "-1-2-3", render_proc.call({})
end
def test_compile_cycle
template = Template.parse("{% for i in (1..3) %}{% cycle 'a', 'b' %}{% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "aba", render_proc.call({})
end
def test_compile_nested_property_access
template = Template.parse("{{ user.profile.name }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
data = { "user" => { "profile" => { "name" => "Alice" } } }
assert_equal "Alice", render_proc.call(data)
end
def test_compile_array_access
template = Template.parse("{{ items[1] }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "b", render_proc.call({ "items" => ["a", "b", "c"] })
end
def test_compile_size_filter
template = Template.parse("{{ items | size }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "3", render_proc.call({ "items" => [1, 2, 3] })
end
def test_compile_join_filter
template = Template.parse("{{ items | join: ', ' }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "a, b, c", render_proc.call({ "items" => ["a", "b", "c"] })
end
def test_compile_split_filter
template = Template.parse("{% assign arr = str | split: ',' %}{{ arr | size }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "3", render_proc.call({ "str" => "a,b,c" })
end
def test_compile_math_filters
template = Template.parse("{{ x | plus: 5 | minus: 2 | times: 3 }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "24", render_proc.call({ "x" => 5 })
end
def test_compile_default_filter
template = Template.parse("{{ x | default: 'nothing' }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "hello", render_proc.call({ "x" => "hello" })
assert_equal "nothing", render_proc.call({ "x" => nil })
assert_equal "nothing", render_proc.call({})
end
def test_compile_first_last_filters
template = Template.parse("{{ items | first }}-{{ items | last }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "a-c", render_proc.call({ "items" => ["a", "b", "c"] })
end
def test_compile_escape_filter
template = Template.parse("{{ html | escape }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "&lt;p&gt;hello&lt;/p&gt;", render_proc.call({ "html" => "<p>hello</p>" })
end
def test_compile_replace_filter
template = Template.parse("{{ str | replace: 'foo', 'bar' }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "bar baz bar", render_proc.call({ "str" => "foo baz foo" })
end
def test_compile_append_prepend_filters
template = Template.parse("{{ name | prepend: 'Hello, ' | append: '!' }}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "Hello, World!", render_proc.call({ "name" => "World" })
end
def test_compile_for_break
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "12", render_proc.call({})
end
def test_compile_for_continue
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "1245", render_proc.call({})
end
def test_compile_comparison_operators
template = Template.parse("{% if x > 5 %}big{% elsif x == 5 %}five{% else %}small{% endif %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "big", render_proc.call({ "x" => 10 })
assert_equal "five", render_proc.call({ "x" => 5 })
assert_equal "small", render_proc.call({ "x" => 2 })
end
def test_compile_and_or_operators
template = Template.parse("{% if a and b %}both{% elsif a or b %}one{% else %}none{% endif %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "both", render_proc.call({ "a" => true, "b" => true })
assert_equal "one", render_proc.call({ "a" => true, "b" => false })
assert_equal "none", render_proc.call({ "a" => false, "b" => false })
end
def test_compile_contains
template = Template.parse("{% if str contains 'hello' %}found{% else %}not found{% endif %}")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assert_equal "found", render_proc.call({ "str" => "say hello world" })
assert_equal "not found", render_proc.call({ "str" => "goodbye" })
end
def test_compile_produces_valid_ruby
template = Template.parse("{% for item in items %}{{ item | upcase }}{% endfor %}")
ruby_code = template.compile_to_ruby
# Should produce valid Ruby syntax
assert_nothing_raised do
eval(ruby_code)
end
end
def test_compile_vs_render_equivalence
templates = [
"Hello, {{ name }}!",
"{% if show %}visible{% else %}hidden{% endif %}",
"{% for i in (1..3) %}{{ i }}{% endfor %}",
"{{ str | upcase | split: '' | join: '-' }}",
"{% assign x = 5 %}{% assign y = x | plus: 3 %}{{ y }}",
]
assigns_list = [
{ "name" => "World", "show" => true, "str" => "hello" },
{ "name" => "Ruby", "show" => false, "str" => "test" },
]
templates.each do |source|
template = Template.parse(source)
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
assigns_list.each do |assigns|
expected = template.render(assigns)
actual = render_proc.call(assigns.dup)
assert_equal expected, actual, "Mismatch for template '#{source}' with assigns #{assigns}"
end
end
end
end