mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-16 09:20:42 -07:00
Add Liquid Drop support for compiled templates
- Create CompiledContext class that duck-types to Liquid::Context - CompiledContext provides: variable lookup, registers, strict_* flags - Update __lookup__ helper to: - Set context on Drops BEFORE accessing their methods - Call to_liquid on objects before lookup - Set context on nested Drop results - Change __lookup__ from def to lambda to capture __context__ closure - Update expression compiler to use __lookup__.call() syntax - Add CompiledTemplate.call options: registers, strict_variables, strict_filters Tests added: - test_compile_with_drop: Basic Drop property access - test_compile_with_drop_context_access: Drop using context to access other vars - test_compile_with_nested_drops: Chained Drop lookups - test_compile_with_forloop_drop: Built-in forloop compatibility - test_compile_with_registers: Drop accessing registers via context All 51 tests pass, 30/30 benchmark templates produce matching output.
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
module Liquid
|
||||
module Compile
|
||||
autoload :CompiledTemplate, 'liquid/compile/compiled_template'
|
||||
autoload :CompiledContext, 'liquid/compile/compiled_context'
|
||||
autoload :CodeGenerator, 'liquid/compile/code_generator'
|
||||
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
|
||||
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# CompiledContext is a lightweight context-like object for compiled templates.
|
||||
#
|
||||
# It duck-types to Liquid::Context well enough for Drops to work, providing:
|
||||
# - Variable lookup via [] and find_variable
|
||||
# - strict_variables flag
|
||||
# - registers hash
|
||||
# - evaluate method for expressions
|
||||
#
|
||||
# This allows Drops to access other variables and use context features
|
||||
# while still running in compiled mode.
|
||||
class CompiledContext
|
||||
attr_reader :assigns, :registers
|
||||
attr_accessor :strict_variables, :strict_filters
|
||||
|
||||
def initialize(assigns, registers: {}, strict_variables: false, strict_filters: false)
|
||||
@assigns = assigns
|
||||
@registers = registers.is_a?(Liquid::Registers) ? registers : Liquid::Registers.new(registers)
|
||||
@strict_variables = strict_variables
|
||||
@strict_filters = strict_filters
|
||||
end
|
||||
|
||||
# Variable lookup - used by Drops to access other variables
|
||||
def [](key)
|
||||
@assigns[key.to_s]
|
||||
end
|
||||
|
||||
# Find a variable by name
|
||||
def find_variable(key)
|
||||
result = @assigns[key.to_s]
|
||||
result = result.to_liquid if result.respond_to?(:to_liquid)
|
||||
result.context = self if result.respond_to?(:context=)
|
||||
result
|
||||
end
|
||||
|
||||
# Evaluate an expression (for Drops that need to evaluate sub-expressions)
|
||||
def evaluate(expr)
|
||||
case expr
|
||||
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
||||
expr
|
||||
when Liquid::VariableLookup
|
||||
expr.evaluate(self)
|
||||
else
|
||||
expr
|
||||
end
|
||||
end
|
||||
|
||||
# Lookup and evaluate - handles Procs in assigns
|
||||
def lookup_and_evaluate(obj, key)
|
||||
value = obj[key]
|
||||
value = value.call(self) if value.is_a?(Proc)
|
||||
value
|
||||
end
|
||||
|
||||
# Handle errors (simplified - just return message)
|
||||
def handle_error(error, _line_number = nil)
|
||||
error.message
|
||||
end
|
||||
|
||||
# Check if execution should be interrupted
|
||||
def interrupt?
|
||||
false
|
||||
end
|
||||
|
||||
# Stub for resource limits (no-op in compiled mode)
|
||||
def resource_limits
|
||||
@resource_limits ||= ResourceLimitStub.new
|
||||
end
|
||||
end
|
||||
|
||||
# Stub for resource limits in compiled mode
|
||||
class ResourceLimitStub
|
||||
def increment_render_score(_score); end
|
||||
def increment_write_score(_output); end
|
||||
def reached?; false; end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -48,15 +48,27 @@ module Liquid
|
||||
# Execute the compiled template with the given assigns
|
||||
# @param assigns [Hash] The variable assignments
|
||||
# @param filter_handler [Object] Optional filter handler to override the default
|
||||
# @param registers [Hash] Optional registers for context
|
||||
# @param strict_variables [Boolean] Raise on undefined variables
|
||||
# @param strict_filters [Boolean] Raise on undefined filters
|
||||
# @return [String] The rendered output
|
||||
def call(assigns = {}, filter_handler: nil)
|
||||
def call(assigns = {}, filter_handler: nil, registers: {}, strict_variables: false, strict_filters: false)
|
||||
proc = to_proc
|
||||
handler = filter_handler || @filter_handler
|
||||
|
||||
# Create a context for Drop support
|
||||
context = CompiledContext.new(
|
||||
assigns,
|
||||
registers: registers,
|
||||
strict_variables: strict_variables,
|
||||
strict_filters: strict_filters
|
||||
)
|
||||
|
||||
# Build arguments based on what the lambda expects
|
||||
args = [assigns]
|
||||
args << @external_tags if has_external_tags?
|
||||
args << handler if has_external_filters?
|
||||
args << context # Always pass context as last arg
|
||||
|
||||
proc.call(*args)
|
||||
end
|
||||
|
||||
@@ -63,16 +63,16 @@ module Liquid
|
||||
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)})"
|
||||
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
|
||||
elsif key.is_a?(Integer)
|
||||
# Numeric index like foo[0]
|
||||
base = "__lookup__(#{base}, #{key})"
|
||||
base = "__lookup__.call(#{base}, #{key})"
|
||||
elsif key.is_a?(String)
|
||||
# Always use __lookup__ which tries key access first,
|
||||
# then falls back to method call for command methods (first, last, size)
|
||||
base = "__lookup__(#{base}, #{key.inspect})"
|
||||
base = "__lookup__.call(#{base}, #{key.inspect})"
|
||||
else
|
||||
base = "__lookup__(#{base}, #{compile(key, compiler)})"
|
||||
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ module Liquid
|
||||
params = ["assigns = {}"]
|
||||
params << "__external_tags__ = {}" unless @external_tags.empty?
|
||||
params << "__filter_handler__ = nil" if @has_external_filters
|
||||
params << "__context__ = nil"
|
||||
|
||||
code.line "->(#{params.join(', ')}) do"
|
||||
|
||||
@@ -215,6 +216,11 @@ module Liquid
|
||||
code.line '__output__ = +""'
|
||||
code.blank_line
|
||||
|
||||
# Create a compiled context if not provided (for Drop support)
|
||||
code.line "# Create context for Drop support"
|
||||
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
|
||||
code.blank_line
|
||||
|
||||
# Compile helper methods if needed
|
||||
if @options[:include_filters]
|
||||
compile_helper_methods(code)
|
||||
@@ -480,11 +486,15 @@ module Liquid
|
||||
code.line "end"
|
||||
code.blank_line
|
||||
|
||||
# Variable lookup helper
|
||||
code.line "def __lookup__(obj, key)"
|
||||
# Variable lookup helper - handles hash/array access, method calls, to_liquid, and drop context
|
||||
code.line "__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.line "# Set context on Drops BEFORE accessing their methods"
|
||||
code.line "obj = obj.to_liquid if obj.respond_to?(:to_liquid)"
|
||||
code.line "obj.context = __context__ if obj.respond_to?(:context=)"
|
||||
code.line "# Now perform the lookup"
|
||||
code.line "result = 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
|
||||
@@ -497,8 +507,12 @@ module Liquid
|
||||
code.line "nil"
|
||||
end
|
||||
code.line "end"
|
||||
code.line "# Convert result to liquid and set context for nested Drops"
|
||||
code.line "result = result.to_liquid if result.respond_to?(:to_liquid)"
|
||||
code.line "result.context = __context__ if result.respond_to?(:context=)"
|
||||
code.line "result"
|
||||
end
|
||||
code.line "end"
|
||||
code.line "}"
|
||||
code.blank_line
|
||||
|
||||
# Output helper that handles nil and arrays
|
||||
|
||||
@@ -438,4 +438,119 @@ class CompileTest < Minitest::Test
|
||||
result = compiled.call({ "x" => "test" })
|
||||
assert_equal "custom:test", result
|
||||
end
|
||||
|
||||
# Test Drop support
|
||||
def test_compile_with_drop
|
||||
# Create a simple Drop class
|
||||
product_drop = Class.new(Liquid::Drop) do
|
||||
def initialize(name, price)
|
||||
super()
|
||||
@name = name
|
||||
@price = price
|
||||
end
|
||||
|
||||
def name
|
||||
@name
|
||||
end
|
||||
|
||||
def price
|
||||
@price
|
||||
end
|
||||
|
||||
def discounted_price
|
||||
@price * 0.9
|
||||
end
|
||||
end
|
||||
|
||||
template = Template.parse("Product: {{ product.name }} costs ${{ product.price }}")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
drop = product_drop.new("Widget", 100)
|
||||
result = compiled.call({ "product" => drop })
|
||||
assert_equal "Product: Widget costs $100", result
|
||||
end
|
||||
|
||||
def test_compile_with_drop_context_access
|
||||
# Create a Drop that uses context
|
||||
context_aware_drop = Class.new(Liquid::Drop) do
|
||||
def initialize(multiplier)
|
||||
super()
|
||||
@multiplier = multiplier
|
||||
end
|
||||
|
||||
def computed_value
|
||||
# Access another variable via context
|
||||
base = @context["base_value"] || 0
|
||||
base * @multiplier
|
||||
end
|
||||
end
|
||||
|
||||
template = Template.parse("Result: {{ calc.computed_value }}")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
drop = context_aware_drop.new(3)
|
||||
result = compiled.call({ "calc" => drop, "base_value" => 10 })
|
||||
assert_equal "Result: 30", result
|
||||
end
|
||||
|
||||
def test_compile_with_nested_drops
|
||||
inner_drop = Class.new(Liquid::Drop) do
|
||||
def initialize(value)
|
||||
super()
|
||||
@value = value
|
||||
end
|
||||
|
||||
def value
|
||||
@value
|
||||
end
|
||||
end
|
||||
|
||||
outer_drop = Class.new(Liquid::Drop) do
|
||||
def initialize(inner)
|
||||
super()
|
||||
@inner = inner
|
||||
end
|
||||
|
||||
def inner
|
||||
@inner
|
||||
end
|
||||
end
|
||||
|
||||
template = Template.parse("{{ outer.inner.value }}")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
inner = inner_drop.new("nested!")
|
||||
outer = outer_drop.new(inner)
|
||||
result = compiled.call({ "outer" => outer })
|
||||
assert_equal "nested!", result
|
||||
end
|
||||
|
||||
def test_compile_with_forloop_drop
|
||||
# ForloopDrop is a built-in Drop - ensure it works
|
||||
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
# Note: Compiled code uses a hash for forloop, not the actual ForloopDrop
|
||||
# This test verifies the hash-based forloop still works
|
||||
result = compiled.call({ "items" => ["a", "b", "c"] })
|
||||
assert_equal "1:a 2:b 3:c ", result
|
||||
end
|
||||
|
||||
def test_compile_with_registers
|
||||
template = Template.parse("{{ product.name }}")
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
# Create a Drop that checks registers
|
||||
product_drop = Class.new(Liquid::Drop) do
|
||||
def name
|
||||
# Access registers through context
|
||||
store = @context.registers[:store] || "Unknown Store"
|
||||
"Product from #{store}"
|
||||
end
|
||||
end
|
||||
|
||||
drop = product_drop.new
|
||||
result = compiled.call({ "product" => drop }, registers: { store: "Acme Corp" })
|
||||
assert_equal "Product from Acme Corp", result
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user