Compare commits

..
Author SHA1 Message Date
Tobi Lutke 9367b8b32e Add Liquid::Box for secure sandboxed template execution
Introduces Liquid::Box which wraps Ruby 4.0's Ruby::Box for secure
template execution. On Ruby < 4.0, provides a polyfill with security
warnings.

Key features:
- Detects Ruby::Box availability at load time
- Loads safe libraries (CGI, Base64, BigDecimal) into sandbox
- Neuters dangerous methods (file IO, process control, eval, etc.)
- Preserves user constants defined before lock!
- Provides setup_gem_load_paths! to enable gem requires in box

Security model: It is safe to expose side-effect-free, non-IO methods
that don't leak objects with dangerous methods. The sandbox blocks
capabilities, not data.
2025-12-31 12:19:53 -04:00
Claude 5cdbce7d6e Improve benchmark output clarity
Add explicit summary showing pre-compiled is X times FASTER,
and explain that "X slower" in comparison means relative to
the fastest option (pre-compiled Ruby). Higher i/s = faster.
2025-12-31 15:25:54 +00:00
Claude 520e86b8c8 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.
2025-12-31 15:21:30 +00:00
Claude 54e3d2328a Fix code injection vulnerability in template name handling
Use .inspect for all template/partial names inserted into generated
code strings to prevent code injection via maliciously crafted
template names like: {% render "foo'); system('rm -rf /'); #" %}
2025-12-31 15:05:20 +00:00
Claude 7be2922f91 Fix compiler output equivalence and add comprehensive tests
- Fix forloop.first/last/size lookups to try hash key before method call
- Fix tablerow cols parameter to use attributes['cols'] not @cols
- Fix tablerow output format to match interpreter (newlines, row boundaries)
- Fix capture compiler to access @body directly instead of iterating nodelist
- Add CompiledTemplate class to encapsulate code and external_tags
- Add external filter support via filter_handler
- Add debug mode warnings for external tag/filter calls
- Add Ruby 3.3 compatibility shim for peek_byte/scan_byte
- Add comprehensive unit tests for output equivalence
- Add benchmark comparing compiled vs interpreted rendering

All 30 test templates now produce identical output between compiled
Ruby and interpreted Liquid. Pre-compiled Ruby is 1.68x faster.
2025-12-31 14:58:06 +00:00
Claude e0f856fd11 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
2025-12-31 14:21:58 +00:00
42 changed files with 3874 additions and 152 deletions
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
+4 -4
View File
@@ -31,8 +31,8 @@ jobs:
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
name: Test Ruby ${{ matrix.entry.ruby }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
@@ -45,8 +45,8 @@ jobs:
memory_profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec rake memory_profile:run
+1 -2
View File
@@ -1,7 +1,6 @@
# Liquid Change Log
## 5.8.7
* Expose body content in the `Doc` tag [James Meng]
## 5.8.1 (unreleased)
## 5.8.1
+1
View File
@@ -89,3 +89,4 @@ require 'liquid/partial_cache'
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
require 'liquid/box'
+493
View File
@@ -0,0 +1,493 @@
# frozen_string_literal: true
# Liquid::Box - Secure sandboxed execution environment for compiled Liquid templates
#
# On Ruby 4.0+, this uses the native Ruby::Box for true isolation.
# On earlier Ruby versions, this provides a polyfill that WARNS about insecurity.
#
# == SECURITY WARNING
#
# The polyfill on Ruby < 4.0 provides NO REAL SECURITY. It's a compatibility shim
# that allows code to run, but malicious templates could potentially escape.
# Use Ruby 4.0+ in production for actual sandboxing.
#
# == Usage
#
# template = Liquid::Template.parse("Hello {{ name }}!")
# compiled = template.compile_to_ruby
#
# # compiled is a Liquid::CompiledTemplate which wraps a Box
# result = compiled.render({ "name" => "World" })
# # => "Hello World!"
#
# # Access the generated Ruby code:
# puts compiled.source
#
module Liquid
# Check if we have Ruby 4.0's native Box AND it's enabled
# Ruby::Box may be defined but disabled (requires RUBY_BOX=1 env var before Ruby starts)
RUBY_BOX_AVAILABLE = begin
if defined?(Ruby::Box)
# Try to create a box to see if it's actually enabled
test_box = Ruby::Box.new
true
else
false
end
rescue RuntimeError => e
# Ruby::Box exists but is disabled
false
end
unless RUBY_BOX_AVAILABLE
# Warn once on load that we're using the insecure polyfill
warn "[Liquid::Box] WARNING: Ruby::Box not available or disabled. " \
"Using INSECURE polyfill. Compiled templates are NOT sandboxed! " \
"(Ruby 4.0+ with RUBY_BOX=1 required for secure execution)" if $VERBOSE
end
# Polyfill for Ruby::Box when not available (Ruby < 4.0)
# This provides the same API but NO SECURITY - it just evals code in the main environment.
module BoxPolyfill
class Box
def initialize
@constants = {}
@binding = TOPLEVEL_BINDING.dup
end
def eval(code)
::Kernel.eval(code, @binding)
end
def const_get(name)
::Object.const_get(name)
end
end
end
# Liquid::Box wraps either Ruby::Box (secure) or BoxPolyfill::Box (insecure)
#
# This provides a secure sandboxed environment for executing compiled Liquid templates.
# On Ruby 4.0+, templates run in true isolation with dangerous methods removed.
# On Ruby < 4.0, templates run without sandboxing (development/testing only).
#
class Box
attr_reader :box
class << self
# Returns true if we have real sandboxing (Ruby 4.0+)
def secure?
RUBY_BOX_AVAILABLE
end
# Create a pre-configured box for Liquid template execution.
# This is the recommended way to get a Box instance.
def create_for_liquid
box = new
box.load_liquid_runtime!
box.lock!
box
end
end
def initialize
@box = RUBY_BOX_AVAILABLE ? Ruby::Box.new : BoxPolyfill::Box.new
@locked = false
@user_constants = []
@warned_insecure = false
end
# Load code into the sandbox before locking.
# Use this to define filters, helpers, and the Liquid runtime.
def load_runtime(code)
raise "Cannot load runtime after lock!" if @locked
before = @box.eval("Object.constants")
@box.eval(code)
after = @box.eval("Object.constants")
@user_constants += (after - before).map(&:to_s)
end
# Load the standard Liquid runtime helpers (LR module).
# Call this before lock! to set up the execution environment.
# The runtime provides all helper methods that compiled templates use.
def load_liquid_runtime!
raise "Cannot load runtime after lock!" if @locked
if RUBY_BOX_AVAILABLE
# Add gem paths to box's load_path so it can find base64, bigdecimal, etc.
# These are safe, side-effect-free libs that only provide pure functions
setup_gem_load_paths!
# Load dependencies INTO the box - we need the actual libraries,
# not reimplementations, to handle all edge cases correctly
@box.require('cgi')
@box.require('base64')
@box.require('bigdecimal')
@box.require('bigdecimal/util') # For String#to_d etc.
# Now load the runtime which captures method references from these
@box.require(RUNTIME_PATH)
else
# Polyfill: just require it normally
require 'cgi'
require 'base64'
require 'bigdecimal'
require 'bigdecimal/util'
require RUNTIME_PATH
end
# Track constants to preserve after lock
@user_constants << "LR"
@user_constants << "LiquidRuntime"
@user_constants << "CGI"
@user_constants << "Base64"
@user_constants << "BigDecimal"
end
# Add gem paths to the box's load_path so require works for gems
def setup_gem_load_paths!
return unless RUBY_BOX_AVAILABLE
# Find gem paths from the main environment's $LOAD_PATH
# and from Gem.path if available
gem_lib_paths = []
# Method 1: Find from $LOAD_PATH entries containing "gems"
$LOAD_PATH.each do |path|
gem_lib_paths << path if path.include?('/gems/')
end
# Method 2: Use Gem.path if available
if defined?(Gem) && Gem.respond_to?(:path)
Gem.path.each do |gem_path|
Dir.glob("#{gem_path}/gems/*/lib").each do |lib_path|
gem_lib_paths << lib_path
end
end
end
# Add unique paths to box's load_path
gem_lib_paths.uniq.each do |path|
@box.load_path << path unless @box.load_path.include?(path)
end
end
# Lock the sandbox. After this:
# - No more runtime can be loaded
# - Dangerous methods are removed (on Ruby 4.0+)
# - Templates can be compiled and executed
def lock!
return if @locked
if RUBY_BOX_AVAILABLE
apply_sandbox!
else
warn_insecure!
end
@locked = true
end
def locked?
@locked
end
# Returns true if this box provides real security (Ruby 4.0+)
def secure?
RUBY_BOX_AVAILABLE
end
# Evaluate code in the sandbox.
# Use this to compile templates into the sandbox.
def eval(code)
raise "Must call lock! before eval" unless @locked
warn_insecure! unless RUBY_BOX_AVAILABLE
@box.eval(code)
end
# Get a constant from the sandbox by name.
def const_get(name)
@box.const_get(name)
end
def [](name)
const_get(name)
end
private
def warn_insecure!
return if @warned_insecure
@warned_insecure = true
$stderr.puts <<~WARNING
SECURITY WARNING: Liquid::Box running WITHOUT sandboxing
Ruby::Box requires Ruby 4.0+. On earlier versions, compiled Liquid templates
execute with FULL Ruby capabilities. This is NOT SECURE for untrusted input.
For production use with untrusted templates, upgrade to Ruby 4.0+.
WARNING
end
# Apply sandbox restrictions (Ruby 4.0+ only)
def apply_sandbox!
neuter_file_system!
neuter_process_control!
neuter_concurrency!
neuter_introspection!
neuter_serialization!
neuter_time!
neuter_environment!
neuter_kernel!
neuter_basic_object!
neuter_object!
neuter_main_singleton!
neuter_module!
cleanup_globals!
remove_dangerous_constants!
end
def neuter_file_system!
@box.eval(<<~'RUBY')
class << File
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << IO
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << Dir
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class IO
[:read, :write, :gets, :puts, :print, :readline, :readlines, :getc, :getbyte,
:sysread, :syswrite, :close, :eof, :eof?, :rewind, :seek].each do |m|
undef_method(m) rescue nil
end
end
RUBY
end
def neuter_process_control!
@box.eval(<<~'RUBY')
class << Process
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << Signal
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_concurrency!
@box.eval(<<~'RUBY')
class << Thread
[:new, :start, :fork, :kill, :exit, :pass, :stop, :main, :current, :list,
:abort_on_exception, :abort_on_exception=].each { |m| undef_method(m) rescue nil }
end
class << Fiber
[:new, :yield, :current].each { |m| undef_method(m) rescue nil }
end
if defined?(Ractor)
class << Ractor
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
end
RUBY
end
def neuter_introspection!
@box.eval(<<~'RUBY')
class << ObjectSpace
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << GC
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
if defined?(RubyVM)
class << RubyVM
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
if defined?(RubyVM::InstructionSequence)
class << RubyVM::InstructionSequence
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
end
end
class << TracePoint
[:new, :stat, :trace].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_serialization!
@box.eval(<<~'RUBY')
class << Marshal
[:dump, :load, :restore].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_time!
# Time is neutered by default for security.
# Templates that need time should receive it via assigns.
@box.eval(<<~'RUBY')
class << Time
[:now, :new, :at, :mktime, :local, :utc, :gm].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_environment!
@box.eval(<<~'RUBY')
ENV.clear rescue nil
class << ENV
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_kernel!
@box.eval(<<~'RUBY')
module Kernel
[:eval, :`, :system, :exec, :spawn, :fork, :binding,
:open, :require, :require_relative, :load, :autoload, :autoload?,
:gets, :readline, :readlines, :select, :test,
:trap, :exit, :exit!, :abort, :at_exit, :syscall, :sleep,
:puts, :print, :printf, :putc, :p, :pp, :warn,
:caller, :caller_locations, :set_trace_func, :trace_var, :untrace_var,
:global_variables, :local_variables,
:gem, :gem_original_require, :Pathname,
].each { |m| undef_method(m) rescue nil }
end
class << Kernel
[:eval, :`, :system, :exec, :spawn, :fork, :binding,
:open, :require, :require_relative, :load,
:puts, :print, :p, :pp, :warn,
].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_basic_object!
@box.eval(<<~'RUBY')
class BasicObject
undef_method(:instance_eval) rescue nil
undef_method(:instance_exec) rescue nil
undef_method(:__send__) rescue nil
end
RUBY
end
def neuter_object!
@box.eval(<<~'RUBY')
class Object
[:gem, :gem_original_require, :require, :require_relative, :load,
:display, :define_singleton_method,
:instance_variable_set, :remove_instance_variable,
:extend, :send, :public_send,
].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_main_singleton!
# 'using' is defined on main's singleton class, must remove before undef_method is gone
@box.eval(<<~'RUBY')
class << self
undef_method(:using) rescue nil
end
RUBY
end
def neuter_module!
@box.eval(<<~'RUBY')
class Module
undef_method(:refine) rescue nil
undef_method(:using) rescue nil
undef_method(:const_set) rescue nil
undef_method(:remove_const) rescue nil
undef_method(:include) rescue nil
undef_method(:prepend) rescue nil
undef_method(:extend) rescue nil
undef_method(:class_eval) rescue nil
undef_method(:module_eval) rescue nil
undef_method(:class_exec) rescue nil
undef_method(:module_exec) rescue nil
undef_method(:define_method) rescue nil
undef_method(:alias_method) rescue nil
undef_method(:module_function) rescue nil
undef_method(:prepend_features) rescue nil
undef_method(:append_features) rescue nil
undef_method(:extend_object) rescue nil
undef_method(:public) rescue nil
undef_method(:private) rescue nil
undef_method(:protected) rescue nil
undef_method(:attr) rescue nil
undef_method(:attr_reader) rescue nil
undef_method(:attr_writer) rescue nil
undef_method(:attr_accessor) rescue nil
# Remove escape hatches LAST
undef_method(:remove_method) rescue nil
undef_method(:undef_method) rescue nil
undef_method(:send) rescue nil
undef_method(:public_send) rescue nil
end
class Class
undef_method(:send) rescue nil
undef_method(:public_send) rescue nil
end
RUBY
end
def cleanup_globals!
@box.eval(<<~'RUBY')
$stdin = nil
$stdout = nil
$stderr = nil
$LOAD_PATH.clear rescue nil
$LOAD_PATH.freeze rescue nil
$LOADED_FEATURES.clear rescue nil
$LOADED_FEATURES.freeze rescue nil
ARGV.clear rescue nil
ARGV.freeze rescue nil
RUBY
end
def remove_dangerous_constants!
keep = %w[
BasicObject Object Module Class Kernel
String Integer Float Numeric Rational Complex
Array Hash Range Set
Symbol Regexp MatchData
TrueClass FalseClass NilClass
Proc Method UnboundMethod
Struct Data
Comparable Enumerable Enumerator
StandardError RuntimeError ArgumentError TypeError NameError
NoMethodError KeyError IndexError StopIteration FrozenError
ZeroDivisionError RangeError FloatDomainError LocalJumpError
Math Random
Exception SystemStackError
] + @user_constants
@box.eval(<<~RUBY)
_keep = #{keep.inspect}
(Object.constants.map(&:to_s) - _keep).each do |c|
Object.send(:remove_const, c.to_sym) rescue nil
end
(Kernel.constants.map(&:to_s) - _keep).each do |c|
Kernel.send(:remove_const, c.to_sym) rescue nil
end
RUBY
end
# Path to the runtime file that gets loaded into the sandbox
RUNTIME_PATH = File.expand_path('compile/runtime.rb', __dir__)
end
end
+83
View File
@@ -0,0 +1,83 @@
# 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 :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'
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
+49
View File
@@ -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
+82
View File
@@ -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
+81
View File
@@ -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
+85
View File
@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Liquid
module Compile
# Represents a compiled Liquid template ready for execution.
#
# Contains the Ruby source code and any external tags/filters that need to be
# passed to the generated lambda at runtime.
#
# Usage:
# compiled = template.compile_to_ruby
# result = compiled.call({ "name" => "World" })
#
# # With custom filters:
# compiled.filter_handler = MyFilterModule
# result = compiled.call({ "name" => "World" })
#
class CompiledTemplate
attr_reader :code, :external_tags
attr_accessor :filter_handler
# @param code [String] The generated Ruby code
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
# @param has_external_filters [Boolean] Whether external filters are used
def initialize(code, external_tags = {}, has_external_filters = false)
@code = code
@external_tags = external_tags
@has_external_filters = has_external_filters
@filter_handler = nil
@proc = nil
end
# Returns true if this template has external tags that need runtime delegation
def has_external_tags?
!@external_tags.empty?
end
# Returns true if this template uses external filters
def has_external_filters?
@has_external_filters
end
# Returns the compiled proc, caching it after first compilation
def to_proc
@proc ||= eval(@code)
end
# 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, 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
# Returns the Ruby code as a string
def to_s
@code
end
end
end
# Make CompiledTemplate available at the top level for convenience
CompiledTemplate = Compile::CompiledTemplate
end
+125
View File
@@ -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
+115
View File
@@ -0,0 +1,115 @@
# 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__.call(#{base}, #{compile(key, compiler)})"
elsif key.is_a?(Integer)
# Numeric index like foo[0]
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__.call(#{base}, #{key.inspect})"
else
base = "__lookup__.call(#{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
+233
View File
@@ -0,0 +1,233 @@
# 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| {'&'=>'&amp;', '>'=>'&gt;', '<'=>'&lt;', '\"'=>'&quot;', \"'\"=>'&#39;'}[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
# Uses __call_filter__ helper which must be provided by the runtime
def self.compile_generic_filter(input, name, args, kwargs, compiler)
# Mark that we're using external filters
compiler.register_external_filter
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(', ')}]"
# Call through the filter helper which delegates to registered filters
"__call_filter__.call(#{name.inspect}, #{input}, #{args_str})"
end
# Compile a filter argument
def self.compile_arg(arg, compiler)
ExpressionCompiler.compile(arg, compiler)
end
end
end
end
+534
View File
@@ -0,0 +1,534 @@
# 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
@external_tags = {} # External tags: var_name => tag object
@external_tag_counter = 0
@has_external_filters = false # Whether we need the filter helper
end
# Mark that we have external filters
def register_external_filter
@has_external_filters = true
end
# Check if external filters are used
def has_external_filters?
@has_external_filters
end
# Register an external tag that will be called at runtime
# @param tag [Liquid::Tag] The tag to register
# @return [String] The variable name for this tag
def register_external_tag(tag)
@external_tag_counter += 1
var_name = "__ext_tag_#{@external_tag_counter}__"
@external_tags[var_name] = tag
var_name
end
# Get all registered external tags
# @return [Hash] Map of variable names to tag objects
def external_tags
@external_tags
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
# @return [Hash] If external tags are used, returns { code: String, external_tags: Hash }
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
# First pass: compile the document body to discover partials and external tags
main_code = CodeGenerator.new
compile_node(@template.root, main_code)
# Determine lambda parameters based on external dependencies
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"
code.indent do
# Initialize the output buffer
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)
code.blank_line
end
# Add external tag runtime helper if needed
unless @external_tags.empty?
compile_external_tag_helper(code)
code.blank_line
end
# Add external filter helper if needed
if @has_external_filters
compile_filter_helper(code)
code.blank_line
end
# 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 helper for calling external tags at runtime
def compile_external_tag_helper(code)
code.line "# Helper for calling external (unknown) tags at runtime"
code.line "__call_external_tag__ = ->(tag_var, tag_assigns) {"
code.indent do
code.line "tag = __external_tags__[tag_var]"
code.line "next '' unless tag"
code.line "# Create a context using the default environment (which has filters registered)"
code.line "ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)"
code.line "output = +''"
code.line "# Use render_to_output_buffer to ensure block tags work correctly"
code.line "tag.render_to_output_buffer(ctx, output)"
code.line "output"
end
code.line "}"
end
# Compile helper for calling external filters at runtime
def compile_filter_helper(code)
code.line "# Helper for calling external (unknown) filters at runtime"
code.line "__call_filter__ = ->(name, input, args) {"
code.indent do
code.line "if __filter_handler__&.respond_to?(name)"
code.indent do
code.line "__filter_handler__.send(name, input, *args)"
end
code.line "else"
code.indent do
code.line "input # Return input unchanged if filter not found"
end
code.line "end"
end
code.line "}"
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.inspect} + ']'"
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
# Unknown tag - delegate to the original tag's render method at runtime
compile_external_tag(tag, code)
end
end
def compile_external_tag(tag, code)
tag_var = register_external_tag(tag)
tag_name = tag.class.name.split('::').last
if debug?
code.line "# External tag: #{tag_name} (delegated to runtime)"
code.line "$stderr.puts '* WARN: Liquid external tag call - #{tag_name} (not compiled, delegated to runtime)' if $VERBOSE"
end
code.line "__output__ << __call_external_tag__.call(#{tag_var.inspect}, assigns)"
end
def find_tag_compiler(tag)
case tag
when Liquid::Unless # Check Unless before If since Unless < If
Tags::UnlessCompiler
when Liquid::If
Tags::IfCompiler
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 - 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 "# 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
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"
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 "}"
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
+130
View File
@@ -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
+18
View File
@@ -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,31 @@
# 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 - access the @body BlockBody directly
body = tag.instance_variable_get(:@body)
if body
BlockBodyCompiler.compile(body, compiler, code)
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
+47
View File
@@ -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
+45
View File
@@ -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
+17
View File
@@ -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
+127
View File
@@ -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
+34
View File
@@ -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
+128
View File
@@ -0,0 +1,128 @@
# 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
if compiler.debug?
code.line "# Inlined partial #{template_name.inspect} at compile time"
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial ' + #{template_name.inspect} + ' at compile time' if $VERBOSE"
end
# 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)
if compiler.debug?
code.line "# Dynamic include (template name from variable)"
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic include (template name from variable)' if $VERBOSE"
end
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
+19
View File
@@ -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
+185
View File
@@ -0,0 +1,185 @@
# 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
if compiler.debug?
code.line "# Inlined partial #{template_name.inspect} at compile time"
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial ' + #{template_name.inspect} + ' at compile time' if $VERBOSE"
end
# 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?
if compiler.debug?
code.line "# Dynamic render (template name from variable)"
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic render (template name from variable)' if $VERBOSE"
end
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,117 @@
# 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)
attributes = tag.attributes
# 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 parameters from attributes hash
cols = attributes['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 from attributes
offset = attributes['offset']
limit = attributes['limit']
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)
# Output initial row (matches interpreter behavior: outputs before loop)
code.line "__output__ << \"<tr class=\\\"row1\\\">\\n\""
# The loop
code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|"
code.indent do
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 and start new row if needed (but not on last item)
code.line "if #{col_var} == #{cols_var} && #{idx_var} != #{len_var} - 1"
code.indent do
code.line "__output__ << \"</tr>\\n<tr class=\\\"row\#{#{row_var} + 1}\\\">\""
code.line "#{col_var} = 0"
code.line "#{row_var} += 1"
end
code.line "end"
code.line "#{idx_var} += 1"
end
code.line "end"
# Close the final row
code.line "__output__ << \"</tr>\\n\""
# 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
+37
View File
@@ -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
+2 -6
View File
@@ -36,8 +36,6 @@ module Liquid
end
def parse(tokens)
@body = +""
while (token = tokens.shift)
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
@@ -45,10 +43,8 @@ module Liquid
if tag_name == block_delimiter
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return
end
@body << token unless token.empty?
end
raise_tag_never_closed(block_name)
@@ -59,11 +55,11 @@ module Liquid
end
def blank?
@body.empty?
true
end
def nodelist
[@body]
[]
end
private
+57
View File
@@ -205,6 +205,63 @@ 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
#
# Returns a CompiledTemplate object with the Ruby code and any external tags
# that need to be passed to the generated lambda.
#
# Usage:
# compiled = template.compile_to_ruby
# result = compiled.call({ "name" => "World" }) # Handles external tags automatically
#
# Or manually:
# proc = eval(compiled.code)
# result = proc.call(assigns, compiled.external_tags)
#
def compile_to_ruby(options = {})
return nil if @root.nil?
require 'liquid/compile'
compiler = Compile::RubyCompiler.new(self, options)
code = compiler.compile
Compile::CompiledTemplate.new(code, compiler.external_tags, compiler.has_external_filters?)
end
private
def configure_options(options)
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.8.7"
VERSION = "5.8.6"
end
+222
View File
@@ -0,0 +1,222 @@
# frozen_string_literal: true
# Benchmark comparing compiled Ruby code vs interpreted Liquid rendering
#
# Usage:
# ruby performance/compile_benchmark.rb
#
# This benchmark compares:
# 1. Standard Liquid render (interpreted)
# 2. Compiled Ruby render (compiled once, executed many times)
# 3. Compile + render (includes compilation overhead)
require 'benchmark/ips'
require_relative 'ruby33_compat' # Add peek_byte for Ruby 3.3 compatibility
require_relative 'shopify/liquid'
require_relative 'shopify/database'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
# Combined filter handler that includes all Shopify filters
class ShopifyFilterHandler
include JsonFilter
include MoneyFilter
include ShopFilter
include TagFilter
include WeightFilter
end
class CompileBenchmarkRunner
def initialize
@templates = []
@compiled_procs = []
@filter_handler = ShopifyFilterHandler.new
# Load test templates
load_templates
end
def load_templates
puts "Loading templates..."
test_dirs = Dir[__dir__ + '/tests/*']
test_dirs.each do |dir|
next unless File.directory?(dir)
Dir[dir + '/*.liquid'].each do |file|
next if File.basename(file) == 'theme.liquid'
source = File.read(file)
template = Liquid::Template.parse(source)
@templates << {
name: File.basename(file),
source: source,
template: template,
assigns: Database.tables.dup,
}
end
end
puts "Loaded #{@templates.size} templates"
# Pre-compile all templates
puts "Pre-compiling templates to Ruby..."
@templates.each do |t|
begin
compiled = t[:template].compile_to_ruby
compiled.filter_handler = @filter_handler # Set the filter handler
t[:compiled] = compiled # CompiledTemplate object
t[:ruby_code] = compiled.code
notes = []
notes << "#{compiled.external_tags.size} external tag(s)" if compiled.has_external_tags?
notes << "external filters" if compiled.has_external_filters?
puts " #{t[:name]}: #{notes.join(', ')}" unless notes.empty?
rescue => e
puts " Warning: Failed to compile #{t[:name]}: #{e.message}"
puts " #{e.backtrace.first(3).join("\n ")}"
t[:compiled] = nil
end
end
compilable = @templates.count { |t| t[:compiled] }
puts "Successfully compiled #{compilable}/#{@templates.size} templates"
puts
end
# Benchmark: Standard Liquid render
def render_interpreted
@templates.each do |t|
t[:template].render!(t[:assigns].dup)
end
end
# Benchmark: Compiled Ruby render (already compiled)
def render_compiled
@templates.each do |t|
next unless t[:compiled]
t[:compiled].call(t[:assigns].dup)
end
end
# Benchmark: Compile + render (includes compilation time)
def compile_and_render
@templates.each do |t|
compiled = t[:template].compile_to_ruby
compiled.filter_handler = @filter_handler
compiled.call(t[:assigns].dup)
end
end
# Show sample output comparison
def verify_output
puts "Verifying output equivalence..."
@templates.each do |t|
next unless t[:compiled]
assigns = t[:assigns].dup
interpreted = t[:template].render!(assigns.dup)
compiled = t[:compiled].call(assigns.dup)
if interpreted == compiled
puts "#{t[:name]}: outputs match"
else
puts "#{t[:name]}: outputs differ!"
puts " Interpreted length: #{interpreted.length}"
puts " Compiled length: #{compiled.length}"
# Show first difference
interpreted.chars.each_with_index do |c, i|
if compiled[i] != c
puts " First diff at position #{i}:"
puts " Interpreted: #{interpreted[i-10, 30].inspect}"
puts " Compiled: #{compiled[i-10, 30].inspect}"
break
end
end
end
end
puts
end
# Show code size comparison
def show_stats
puts "Template Statistics:"
puts "=" * 60
total_liquid_size = 0
total_ruby_size = 0
@templates.each do |t|
next unless t[:ruby_code]
liquid_size = t[:source].length
ruby_size = t[:ruby_code].length
total_liquid_size += liquid_size
total_ruby_size += ruby_size
ratio = ruby_size.to_f / liquid_size
puts " #{t[:name]}: Liquid=#{liquid_size}b, Ruby=#{ruby_size}b (#{ratio.round(1)}x)"
end
puts "-" * 60
puts " Total: Liquid=#{total_liquid_size}b, Ruby=#{total_ruby_size}b"
puts
end
def run_benchmark
puts "Running benchmark..."
puts "=" * 60
puts
# Run quick timing to get approximate values for summary
iterations = 50
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
iterations.times { render_interpreted }
interpreted_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
iterations.times { render_compiled }
compiled_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
speedup = interpreted_time / compiled_time
Benchmark.ips do |x|
x.time = 10
x.warmup = 5
x.report("Liquid render (interpreted):") do
render_interpreted
end
x.report("Ruby render (pre-compiled):") do
render_compiled
end
x.report("Compile + render:") do
compile_and_render
end
x.compare!
end
# Print clear summary
puts
puts "=" * 60
puts "SUMMARY:"
puts " ✓ Pre-compiled Ruby is #{speedup.round(2)}x FASTER than interpreted Liquid"
puts
puts " (The 'X slower' above means compared to the fastest option,"
puts " which is pre-compiled Ruby. Higher i/s = faster.)"
puts "=" * 60
end
end
# Run the benchmark
runner = CompileBenchmarkRunner.new
runner.show_stats
runner.verify_output
runner.run_benchmark
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
# Compatibility shim for Ruby 3.3
# The Liquid library uses peek_byte and scan_byte which are only available in Ruby 3.4+
require 'strscan'
unless StringScanner.method_defined?(:peek_byte)
class StringScanner
def peek_byte
return nil if eos?
string.getbyte(pos)
end
end
end
unless StringScanner.method_defined?(:scan_byte)
class StringScanner
def scan_byte
return nil if eos?
byte = string.getbyte(pos)
self.pos += 1
byte
end
end
end
+556
View File
@@ -0,0 +1,556 @@
# frozen_string_literal: true
require 'test_helper'
# Ruby 3.3 compatibility for peek_byte and scan_byte
require 'strscan'
unless StringScanner.method_defined?(:peek_byte)
class StringScanner
def peek_byte
return nil if eos?
string.getbyte(pos)
end
end
end
unless StringScanner.method_defined?(:scan_byte)
class StringScanner
def scan_byte
return nil if eos?
byte = string.getbyte(pos)
self.pos += 1
byte
end
end
end
class CompileTest < Minitest::Test
include Liquid
def test_compile_simple_string
template = Template.parse("Hello, World!")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({})
end
def test_compile_variable
template = Template.parse("Hello, {{ name }}!")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
end
def test_compile_variable_with_filter
template = Template.parse("{{ name | upcase }}")
compiled = template.compile_to_ruby
assert_equal "WORLD", compiled.call({ "name" => "world" })
end
def test_compile_variable_with_multiple_filters
template = Template.parse("{{ name | downcase | capitalize }}")
compiled = template.compile_to_ruby
assert_equal "Hello", compiled.call({ "name" => "HELLO" })
end
def test_compile_if_true
template = Template.parse("{% if show %}visible{% endif %}")
compiled = template.compile_to_ruby
assert_equal "visible", compiled.call({ "show" => true })
assert_equal "", compiled.call({ "show" => false })
end
def test_compile_if_else
template = Template.parse("{% if show %}yes{% else %}no{% endif %}")
compiled = template.compile_to_ruby
assert_equal "yes", compiled.call({ "show" => true })
assert_equal "no", compiled.call({ "show" => false })
end
def test_compile_if_elsif
template = Template.parse("{% if x == 1 %}one{% elsif x == 2 %}two{% else %}other{% endif %}")
compiled = template.compile_to_ruby
assert_equal "one", compiled.call({ "x" => 1 })
assert_equal "two", compiled.call({ "x" => 2 })
assert_equal "other", compiled.call({ "x" => 3 })
end
def test_compile_unless
template = Template.parse("{% unless hidden %}visible{% endunless %}")
compiled = template.compile_to_ruby
assert_equal "visible", compiled.call({ "hidden" => false })
assert_equal "", compiled.call({ "hidden" => true })
end
def test_compile_for_loop
template = Template.parse("{% for item in items %}{{ item }} {% endfor %}")
compiled = template.compile_to_ruby
assert_equal "a b c ", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_for_loop_with_forloop
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
compiled = template.compile_to_ruby
assert_equal "1:a 2:b 3:c ", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_for_loop_else
template = Template.parse("{% for item in items %}{{ item }}{% else %}empty{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "abc", compiled.call({ "items" => ["a", "b", "c"] })
assert_equal "empty", compiled.call({ "items" => [] })
end
def test_compile_for_with_limit
template = Template.parse("{% for item in items limit:2 %}{{ item }}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "ab", compiled.call({ "items" => ["a", "b", "c", "d"] })
end
def test_compile_assign
template = Template.parse("{% assign x = 'hello' %}{{ x }}")
compiled = template.compile_to_ruby
assert_equal "hello", compiled.call({})
end
def test_compile_assign_with_filter
template = Template.parse("{% assign x = name | upcase %}{{ x }}")
compiled = template.compile_to_ruby
assert_equal "WORLD", compiled.call({ "name" => "world" })
end
def test_compile_capture
template = Template.parse("{% capture greeting %}Hello, {{ name }}!{% endcapture %}{{ greeting }}")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
end
def test_compile_case
template = Template.parse("{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}")
compiled = template.compile_to_ruby
assert_equal "one", compiled.call({ "x" => 1 })
assert_equal "two", compiled.call({ "x" => 2 })
assert_equal "other", compiled.call({ "x" => 3 })
end
def test_compile_raw
template = Template.parse("{% raw %}{{ not_a_variable }}{% endraw %}")
compiled = template.compile_to_ruby
assert_equal "{{ not_a_variable }}", compiled.call({})
end
def test_compile_comment
template = Template.parse("before{% comment %}hidden{% endcomment %}after")
compiled = template.compile_to_ruby
assert_equal "beforeafter", compiled.call({})
end
def test_compile_increment
template = Template.parse("{% increment x %}{% increment x %}{% increment x %}")
compiled = template.compile_to_ruby
assert_equal "012", compiled.call({})
end
def test_compile_decrement
template = Template.parse("{% decrement x %}{% decrement x %}{% decrement x %}")
compiled = template.compile_to_ruby
assert_equal "-1-2-3", compiled.call({})
end
def test_compile_cycle
template = Template.parse("{% for i in (1..3) %}{% cycle 'a', 'b' %}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "aba", compiled.call({})
end
def test_compile_nested_property_access
template = Template.parse("{{ user.profile.name }}")
compiled = template.compile_to_ruby
data = { "user" => { "profile" => { "name" => "Alice" } } }
assert_equal "Alice", compiled.call(data)
end
def test_compile_array_access
template = Template.parse("{{ items[1] }}")
compiled = template.compile_to_ruby
assert_equal "b", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_size_filter
template = Template.parse("{{ items | size }}")
compiled = template.compile_to_ruby
assert_equal "3", compiled.call({ "items" => [1, 2, 3] })
end
def test_compile_join_filter
template = Template.parse("{{ items | join: ', ' }}")
compiled = template.compile_to_ruby
assert_equal "a, b, c", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_split_filter
template = Template.parse("{% assign arr = str | split: ',' %}{{ arr | size }}")
compiled = template.compile_to_ruby
assert_equal "3", compiled.call({ "str" => "a,b,c" })
end
def test_compile_math_filters
template = Template.parse("{{ x | plus: 5 | minus: 2 | times: 3 }}")
compiled = template.compile_to_ruby
assert_equal "24", compiled.call({ "x" => 5 })
end
def test_compile_default_filter
template = Template.parse("{{ x | default: 'nothing' }}")
compiled = template.compile_to_ruby
assert_equal "hello", compiled.call({ "x" => "hello" })
assert_equal "nothing", compiled.call({ "x" => nil })
assert_equal "nothing", compiled.call({})
end
def test_compile_first_last_filters
template = Template.parse("{{ items | first }}-{{ items | last }}")
compiled = template.compile_to_ruby
assert_equal "a-c", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_escape_filter
template = Template.parse("{{ html | escape }}")
compiled = template.compile_to_ruby
assert_equal "&lt;p&gt;hello&lt;/p&gt;", compiled.call({ "html" => "<p>hello</p>" })
end
def test_compile_replace_filter
template = Template.parse("{{ str | replace: 'foo', 'bar' }}")
compiled = template.compile_to_ruby
assert_equal "bar baz bar", compiled.call({ "str" => "foo baz foo" })
end
def test_compile_append_prepend_filters
template = Template.parse("{{ name | prepend: 'Hello, ' | append: '!' }}")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
end
def test_compile_for_break
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "12", compiled.call({})
end
def test_compile_for_continue
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "1245", compiled.call({})
end
def test_compile_comparison_operators
template = Template.parse("{% if x > 5 %}big{% elsif x == 5 %}five{% else %}small{% endif %}")
compiled = template.compile_to_ruby
assert_equal "big", compiled.call({ "x" => 10 })
assert_equal "five", compiled.call({ "x" => 5 })
assert_equal "small", compiled.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 %}")
compiled = template.compile_to_ruby
assert_equal "both", compiled.call({ "a" => true, "b" => true })
assert_equal "one", compiled.call({ "a" => true, "b" => false })
assert_equal "none", compiled.call({ "a" => false, "b" => false })
end
def test_compile_contains
template = Template.parse("{% if str contains 'hello' %}found{% else %}not found{% endif %}")
compiled = template.compile_to_ruby
assert_equal "found", compiled.call({ "str" => "say hello world" })
assert_equal "not found", compiled.call({ "str" => "goodbye" })
end
def test_compile_produces_valid_ruby
template = Template.parse("{% for item in items %}{{ item | upcase }}{% endfor %}")
compiled = template.compile_to_ruby
# Should produce valid Ruby syntax - if eval succeeds without error, it's valid
proc = eval(compiled.code)
assert_kind_of Proc, proc
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)
compiled = template.compile_to_ruby
assigns_list.each do |assigns|
expected = template.render(assigns)
actual = compiled.call(assigns.dup)
assert_equal expected, actual, "Mismatch for template '#{source}' with assigns #{assigns}"
end
end
end
def test_compiled_template_class
template = Template.parse("Hello, {{ name }}!")
compiled = template.compile_to_ruby
assert_instance_of Liquid::Compile::CompiledTemplate, compiled
assert_respond_to compiled, :call
assert_respond_to compiled, :code
assert_respond_to compiled, :to_s
assert_respond_to compiled, :to_proc
end
def test_compiled_template_code
template = Template.parse("Hello!")
compiled = template.compile_to_ruby
assert_kind_of String, compiled.code
assert_includes compiled.code, "__output__"
assert_includes compiled.code, "Hello!"
end
def test_external_tags_indicator
# A simple template should have no external tags
template = Template.parse("Hello!")
compiled = template.compile_to_ruby
assert_equal false, compiled.has_external_tags?
assert_empty compiled.external_tags
end
def test_external_filters_indicator
# A simple template should have no external filters
template = Template.parse("{{ name | upcase }}")
compiled = template.compile_to_ruby
assert_equal false, compiled.has_external_filters?
end
def test_comprehensive_output_equivalence
# Comprehensive test comparing compiled vs interpreted output
test_cases = [
# Basic variables
{ source: "{{ x }}", assigns: { "x" => "hello" } },
{ source: "{{ x }}", assigns: { "x" => 123 } },
{ source: "{{ x }}", assigns: { "x" => nil } },
# Nested access
{ source: "{{ a.b.c }}", assigns: { "a" => { "b" => { "c" => "deep" } } } },
{ source: "{{ items[0] }}", assigns: { "items" => ["first", "second"] } },
# Filters
{ source: "{{ x | upcase }}", assigns: { "x" => "hello" } },
{ source: "{{ x | size }}", assigns: { "x" => [1, 2, 3] } },
{ source: "{{ x | default: 'fallback' }}", assigns: { "x" => nil } },
{ source: "{{ x | plus: 10 }}", assigns: { "x" => 5 } },
{ source: "{{ x | split: ',' | first }}", assigns: { "x" => "a,b,c" } },
# Conditionals
{ source: "{% if x %}yes{% endif %}", assigns: { "x" => true } },
{ source: "{% if x %}yes{% endif %}", assigns: { "x" => false } },
{ source: "{% if x > 5 %}big{% else %}small{% endif %}", assigns: { "x" => 10 } },
# Loops
{ source: "{% for i in items %}{{ i }}{% endfor %}", assigns: { "items" => [1, 2, 3] } },
{ source: "{% for i in (1..3) %}{{ i }}{% endfor %}", assigns: {} },
{ source: "{% for i in items %}{{ forloop.index }}{% endfor %}", assigns: { "items" => %w[a b] } },
{ source: "{% for i in items %}{% if forloop.first %}first{% endif %}{% endfor %}", assigns: { "items" => [1, 2] } },
# Assignments
{ source: "{% assign y = x | upcase %}{{ y }}", assigns: { "x" => "hello" } },
{ source: "{% capture c %}Hello {{ x }}{% endcapture %}{{ c }}", assigns: { "x" => "World" } },
# Case
{ source: "{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}", assigns: { "x" => 1 } },
{ source: "{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}", assigns: { "x" => 3 } },
# Increment/Decrement
{ source: "{% increment x %}{% increment x %}", assigns: {} },
{ source: "{% decrement x %}{% decrement x %}", assigns: {} },
# Cycle
{ source: "{% for i in (1..4) %}{% cycle 'a', 'b' %}{% endfor %}", assigns: {} },
# Break/Continue
{ source: "{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}", assigns: {} },
{ source: "{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}", assigns: {} },
# Raw
{ source: "{% raw %}{{ not a var }}{% endraw %}", assigns: {} },
# Comment
{ source: "before{% comment %}hidden{% endcomment %}after", assigns: {} },
# Tablerow
{ source: "{% tablerow i in items cols:2 %}{{ i }}{% endtablerow %}", assigns: { "items" => [1, 2, 3] } },
]
test_cases.each do |tc|
source = tc[:source]
assigns = tc[:assigns]
template = Template.parse(source)
compiled = template.compile_to_ruby
expected = template.render(assigns.dup)
actual = compiled.call(assigns.dup)
assert_equal expected, actual, "Output mismatch for: #{source.inspect}\nAssigns: #{assigns.inspect}"
end
end
def test_debug_mode_adds_comments
template = Template.parse("{{ name }}")
compiled = template.compile_to_ruby(debug: true)
assert_includes compiled.code, "# LIQUID"
assert_includes compiled.code, "# Compiled from Liquid template"
end
def test_filter_handler_can_be_set
# Create a custom filter module
filter_mod = Module.new do
def custom_filter(input)
"custom:#{input}"
end
end
class_with_filter = Class.new do
include filter_mod
end
template = Template.parse("{{ x | custom_filter }}")
compiled = template.compile_to_ruby
compiled.filter_handler = class_with_filter.new
# The template should use the custom filter
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
-25
View File
@@ -1,25 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class EnvironmentTest < Minitest::Test
include Liquid
class UnsubscribeFooter < Liquid::Tag
def render(_context)
'Unsubscribe Footer'
end
end
def test_custom_tag
email_environment = Liquid::Environment.build do |environment|
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
end
assert(email_environment.tags["unsubscribe_footer"])
assert(email_environment.tag_for_name("unsubscribe_footer"))
template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment)
assert_equal('Unsubscribe Footer', template.render)
end
end
+5 -108
View File
@@ -20,21 +20,6 @@ class DocTagUnitTest < Minitest::Test
assert_template_result('', template)
end
def test_doc_tag_body_content
doc_content = " Documentation content\n @param {string} foo - test\n"
template_source = "{% doc %}#{doc_content}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(doc_content, doc_tag.nodelist.first.to_s)
end
def test_doc_tag_does_not_support_extra_arguments
error = assert_raises(Liquid::SyntaxError) do
template = <<~LIQUID.chomp
@@ -131,20 +116,6 @@ class DocTagUnitTest < Minitest::Test
assert_template_result('', template)
end
def test_doc_tag_captures_token_before_enddoc
template_source = "{% doc %}{{ incomplete{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s)
end
def test_doc_tag_preserves_error_line_numbers
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
{% doc %}
@@ -174,11 +145,11 @@ class DocTagUnitTest < Minitest::Test
def test_doc_tag_delimiter_handling
assert_template_result('', <<~LIQUID.chomp)
{%- if true -%}
{%- doc -%}
{%- docEXTRA -%}wut{% enddocEXTRA -%}xyz
{%- enddoc -%}
{%- endif -%}
{% if true %}
{% doc %}
{% docEXTRA %}wut{% enddocEXTRA %}xyz
{% enddoc %}
{% endif %}
LIQUID
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
@@ -196,80 +167,6 @@ class DocTagUnitTest < Minitest::Test
)
end
def test_doc_tag_blank_with_empty_content
template_source = "{% doc %}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(true, doc_tag.blank?)
end
def test_doc_tag_blank_with_content
template_source = "{% doc %}Some documentation{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(false, doc_tag.blank?)
end
def test_doc_tag_blank_with_whitespace_only
template_source = "{% doc %} {% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(false, doc_tag.blank?)
end
def test_doc_tag_nodelist_returns_array_with_body
doc_content = "Documentation content\n@param {string} foo"
template_source = "{% doc %}#{doc_content}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal([doc_content], doc_tag.nodelist)
assert_equal(1, doc_tag.nodelist.length)
assert_equal(doc_content, doc_tag.nodelist.first)
end
def test_doc_tag_nodelist_with_empty_content
template_source = "{% doc %}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal([""], doc_tag.nodelist)
assert_equal(1, doc_tag.nodelist.length)
end
private
def traversal(template)