Skip to content

Commit 520e86b

Browse files
committed
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.
1 parent 54e3d23 commit 520e86b

File tree

6 files changed

+232
-9
lines changed

6 files changed

+232
-9
lines changed

lib/liquid/compile.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
module Liquid
5050
module Compile
5151
autoload :CompiledTemplate, 'liquid/compile/compiled_template'
52+
autoload :CompiledContext, 'liquid/compile/compiled_context'
5253
autoload :CodeGenerator, 'liquid/compile/code_generator'
5354
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
5455
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
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+
module Liquid
4+
module Compile
5+
# CompiledContext is a lightweight context-like object for compiled templates.
6+
#
7+
# It duck-types to Liquid::Context well enough for Drops to work, providing:
8+
# - Variable lookup via [] and find_variable
9+
# - strict_variables flag
10+
# - registers hash
11+
# - evaluate method for expressions
12+
#
13+
# This allows Drops to access other variables and use context features
14+
# while still running in compiled mode.
15+
class CompiledContext
16+
attr_reader :assigns, :registers
17+
attr_accessor :strict_variables, :strict_filters
18+
19+
def initialize(assigns, registers: {}, strict_variables: false, strict_filters: false)
20+
@assigns = assigns
21+
@registers = registers.is_a?(Liquid::Registers) ? registers : Liquid::Registers.new(registers)
22+
@strict_variables = strict_variables
23+
@strict_filters = strict_filters
24+
end
25+
26+
# Variable lookup - used by Drops to access other variables
27+
def [](key)
28+
@assigns[key.to_s]
29+
end
30+
31+
# Find a variable by name
32+
def find_variable(key)
33+
result = @assigns[key.to_s]
34+
result = result.to_liquid if result.respond_to?(:to_liquid)
35+
result.context = self if result.respond_to?(:context=)
36+
result
37+
end
38+
39+
# Evaluate an expression (for Drops that need to evaluate sub-expressions)
40+
def evaluate(expr)
41+
case expr
42+
when String, Integer, Float, TrueClass, FalseClass, NilClass
43+
expr
44+
when Liquid::VariableLookup
45+
expr.evaluate(self)
46+
else
47+
expr
48+
end
49+
end
50+
51+
# Lookup and evaluate - handles Procs in assigns
52+
def lookup_and_evaluate(obj, key)
53+
value = obj[key]
54+
value = value.call(self) if value.is_a?(Proc)
55+
value
56+
end
57+
58+
# Handle errors (simplified - just return message)
59+
def handle_error(error, _line_number = nil)
60+
error.message
61+
end
62+
63+
# Check if execution should be interrupted
64+
def interrupt?
65+
false
66+
end
67+
68+
# Stub for resource limits (no-op in compiled mode)
69+
def resource_limits
70+
@resource_limits ||= ResourceLimitStub.new
71+
end
72+
end
73+
74+
# Stub for resource limits in compiled mode
75+
class ResourceLimitStub
76+
def increment_render_score(_score); end
77+
def increment_write_score(_output); end
78+
def reached?; false; end
79+
end
80+
end
81+
end

lib/liquid/compile/compiled_template.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,27 @@ def to_proc
4848
# Execute the compiled template with the given assigns
4949
# @param assigns [Hash] The variable assignments
5050
# @param filter_handler [Object] Optional filter handler to override the default
51+
# @param registers [Hash] Optional registers for context
52+
# @param strict_variables [Boolean] Raise on undefined variables
53+
# @param strict_filters [Boolean] Raise on undefined filters
5154
# @return [String] The rendered output
52-
def call(assigns = {}, filter_handler: nil)
55+
def call(assigns = {}, filter_handler: nil, registers: {}, strict_variables: false, strict_filters: false)
5356
proc = to_proc
5457
handler = filter_handler || @filter_handler
5558

59+
# Create a context for Drop support
60+
context = CompiledContext.new(
61+
assigns,
62+
registers: registers,
63+
strict_variables: strict_variables,
64+
strict_filters: strict_filters
65+
)
66+
5667
# Build arguments based on what the lambda expects
5768
args = [assigns]
5869
args << @external_tags if has_external_tags?
5970
args << handler if has_external_filters?
71+
args << context # Always pass context as last arg
6072

6173
proc.call(*args)
6274
end

lib/liquid/compile/expression_compiler.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,16 @@ def self.compile_variable_lookup(lookup, compiler)
6363
lookup.lookups.each_with_index do |key, index|
6464
if key.is_a?(VariableLookup) || key.is_a?(RangeLookup)
6565
# Dynamic key like foo[expr]
66-
base = "__lookup__(#{base}, #{compile(key, compiler)})"
66+
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
6767
elsif key.is_a?(Integer)
6868
# Numeric index like foo[0]
69-
base = "__lookup__(#{base}, #{key})"
69+
base = "__lookup__.call(#{base}, #{key})"
7070
elsif key.is_a?(String)
7171
# Always use __lookup__ which tries key access first,
7272
# then falls back to method call for command methods (first, last, size)
73-
base = "__lookup__(#{base}, #{key.inspect})"
73+
base = "__lookup__.call(#{base}, #{key.inspect})"
7474
else
75-
base = "__lookup__(#{base}, #{compile(key, compiler)})"
75+
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
7676
end
7777
end
7878

lib/liquid/compile/ruby_compiler.rb

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def compile
207207
params = ["assigns = {}"]
208208
params << "__external_tags__ = {}" unless @external_tags.empty?
209209
params << "__filter_handler__ = nil" if @has_external_filters
210+
params << "__context__ = nil"
210211

211212
code.line "->(#{params.join(', ')}) do"
212213

@@ -215,6 +216,11 @@ def compile
215216
code.line '__output__ = +""'
216217
code.blank_line
217218

219+
# Create a compiled context if not provided (for Drop support)
220+
code.line "# Create context for Drop support"
221+
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
222+
code.blank_line
223+
218224
# Compile helper methods if needed
219225
if @options[:include_filters]
220226
compile_helper_methods(code)
@@ -480,11 +486,15 @@ def compile_helper_methods(code)
480486
code.line "end"
481487
code.blank_line
482488

483-
# Variable lookup helper
484-
code.line "def __lookup__(obj, key)"
489+
# Variable lookup helper - handles hash/array access, method calls, to_liquid, and drop context
490+
code.line "__lookup__ = ->(obj, key) {"
485491
code.indent do
486492
code.line "return nil if obj.nil?"
487-
code.line "if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))"
493+
code.line "# Set context on Drops BEFORE accessing their methods"
494+
code.line "obj = obj.to_liquid if obj.respond_to?(:to_liquid)"
495+
code.line "obj.context = __context__ if obj.respond_to?(:context=)"
496+
code.line "# Now perform the lookup"
497+
code.line "result = if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))"
488498
code.indent do
489499
code.line "obj[key]"
490500
end
@@ -497,8 +507,12 @@ def compile_helper_methods(code)
497507
code.line "nil"
498508
end
499509
code.line "end"
510+
code.line "# Convert result to liquid and set context for nested Drops"
511+
code.line "result = result.to_liquid if result.respond_to?(:to_liquid)"
512+
code.line "result.context = __context__ if result.respond_to?(:context=)"
513+
code.line "result"
500514
end
501-
code.line "end"
515+
code.line "}"
502516
code.blank_line
503517

504518
# Output helper that handles nil and arrays

test/unit/compile_test.rb

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,119 @@ def custom_filter(input)
438438
result = compiled.call({ "x" => "test" })
439439
assert_equal "custom:test", result
440440
end
441+
442+
# Test Drop support
443+
def test_compile_with_drop
444+
# Create a simple Drop class
445+
product_drop = Class.new(Liquid::Drop) do
446+
def initialize(name, price)
447+
super()
448+
@name = name
449+
@price = price
450+
end
451+
452+
def name
453+
@name
454+
end
455+
456+
def price
457+
@price
458+
end
459+
460+
def discounted_price
461+
@price * 0.9
462+
end
463+
end
464+
465+
template = Template.parse("Product: {{ product.name }} costs ${{ product.price }}")
466+
compiled = template.compile_to_ruby
467+
468+
drop = product_drop.new("Widget", 100)
469+
result = compiled.call({ "product" => drop })
470+
assert_equal "Product: Widget costs $100", result
471+
end
472+
473+
def test_compile_with_drop_context_access
474+
# Create a Drop that uses context
475+
context_aware_drop = Class.new(Liquid::Drop) do
476+
def initialize(multiplier)
477+
super()
478+
@multiplier = multiplier
479+
end
480+
481+
def computed_value
482+
# Access another variable via context
483+
base = @context["base_value"] || 0
484+
base * @multiplier
485+
end
486+
end
487+
488+
template = Template.parse("Result: {{ calc.computed_value }}")
489+
compiled = template.compile_to_ruby
490+
491+
drop = context_aware_drop.new(3)
492+
result = compiled.call({ "calc" => drop, "base_value" => 10 })
493+
assert_equal "Result: 30", result
494+
end
495+
496+
def test_compile_with_nested_drops
497+
inner_drop = Class.new(Liquid::Drop) do
498+
def initialize(value)
499+
super()
500+
@value = value
501+
end
502+
503+
def value
504+
@value
505+
end
506+
end
507+
508+
outer_drop = Class.new(Liquid::Drop) do
509+
def initialize(inner)
510+
super()
511+
@inner = inner
512+
end
513+
514+
def inner
515+
@inner
516+
end
517+
end
518+
519+
template = Template.parse("{{ outer.inner.value }}")
520+
compiled = template.compile_to_ruby
521+
522+
inner = inner_drop.new("nested!")
523+
outer = outer_drop.new(inner)
524+
result = compiled.call({ "outer" => outer })
525+
assert_equal "nested!", result
526+
end
527+
528+
def test_compile_with_forloop_drop
529+
# ForloopDrop is a built-in Drop - ensure it works
530+
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
531+
compiled = template.compile_to_ruby
532+
533+
# Note: Compiled code uses a hash for forloop, not the actual ForloopDrop
534+
# This test verifies the hash-based forloop still works
535+
result = compiled.call({ "items" => ["a", "b", "c"] })
536+
assert_equal "1:a 2:b 3:c ", result
537+
end
538+
539+
def test_compile_with_registers
540+
template = Template.parse("{{ product.name }}")
541+
compiled = template.compile_to_ruby
542+
543+
# Create a Drop that checks registers
544+
product_drop = Class.new(Liquid::Drop) do
545+
def name
546+
# Access registers through context
547+
store = @context.registers[:store] || "Unknown Store"
548+
"Product from #{store}"
549+
end
550+
end
551+
552+
drop = product_drop.new
553+
result = compiled.call({ "product" => drop }, registers: { store: "Acme Corp" })
554+
assert_equal "Product from Acme Corp", result
555+
end
441556
end

0 commit comments

Comments
 (0)