mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -07:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9416d7a9ef | ||
|
|
542deb4a21 | ||
|
|
c5a44be104 | ||
|
|
652b9c0897 | ||
|
|
6c0d599c89 | ||
|
|
560d2ce9d4 | ||
|
|
154343e64f | ||
|
|
6e680a35b3 | ||
|
|
9367b8b32e | ||
|
|
5cdbce7d6e | ||
|
|
520e86b8c8 | ||
|
|
54e3d2328a | ||
|
|
7be2922f91 | ||
|
|
e0f856fd11 |
@@ -0,0 +1,70 @@
|
||||
# Liquid Compiled Template Optimization Log
|
||||
|
||||
This document tracks optimizations made to the compiled Liquid template engine.
|
||||
Each entry shows before/after code and measured impact.
|
||||
|
||||
---
|
||||
|
||||
## Baseline Measurement
|
||||
|
||||
**Date:** 2024-12-31
|
||||
**Commit:** (pending profiler implementation)
|
||||
|
||||
### Current State
|
||||
|
||||
The compiled template engine generates Ruby code from Liquid templates.
|
||||
Before optimizations, here's a sample of generated code for a simple loop:
|
||||
|
||||
```ruby
|
||||
# Template: {% for product in products %}{{ forloop.index }}: {{ product.name }}{% endfor %}
|
||||
|
||||
->(assigns, __context__, __external__) do
|
||||
__output__ = +""
|
||||
|
||||
__coll1__ = assigns["products"]
|
||||
__coll1__ = __coll1__.to_a if __coll1__.is_a?(Range)
|
||||
__len3__ = __coll1__.respond_to?(:length) ? __coll1__.length : 0
|
||||
__idx2__ = 0
|
||||
catch(:__loop__break__) do
|
||||
(__coll1__.respond_to?(:each) ? __coll1__ : []).each do |__item__|
|
||||
catch(:__loop__continue__) do
|
||||
assigns["product"] = __item__
|
||||
assigns['forloop'] = {
|
||||
'name' => "product-products",
|
||||
'length' => __len3__,
|
||||
'index' => __idx2__ + 1,
|
||||
'index0' => __idx2__,
|
||||
'rindex' => __len3__ - __idx2__,
|
||||
'rindex0' => __len3__ - __idx2__ - 1,
|
||||
'first' => __idx2__ == 0,
|
||||
'last' => __idx2__ == __len3__ - 1,
|
||||
}
|
||||
__output__ << LR.output(LR.lookup(assigns["forloop"], "index", __context__))
|
||||
__output__ << ": "
|
||||
__output__ << LR.output(LR.lookup(assigns["product"], "name", __context__))
|
||||
end
|
||||
__idx2__ += 1
|
||||
end
|
||||
end
|
||||
assigns.delete("product")
|
||||
assigns.delete('forloop')
|
||||
|
||||
__output__
|
||||
end
|
||||
```
|
||||
|
||||
### Issues Identified
|
||||
|
||||
1. **catch/throw overhead** - Used even when no break/continue in loop
|
||||
2. **Hash allocation per iteration** - 8 key/value pairs computed every time
|
||||
3. **respond_to? checks** - Redundant after type is known
|
||||
4. **LR.lookup for forloop** - Unnecessary indirection for known hash
|
||||
5. **String literals not frozen** - Allocates on each render
|
||||
6. **Output buffer grows dynamically** - No pre-allocation
|
||||
|
||||
---
|
||||
|
||||
## Optimization Log
|
||||
|
||||
<!-- Entries will be added here as optimizations are implemented -->
|
||||
|
||||
@@ -89,3 +89,4 @@ require 'liquid/partial_cache'
|
||||
require 'liquid/usage'
|
||||
require 'liquid/registers'
|
||||
require 'liquid/template_factory'
|
||||
require 'liquid/box'
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
# 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.
|
||||
@box.require('date') # For date filter
|
||||
@box.require('time') # For Time.parse
|
||||
|
||||
# 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 'date'
|
||||
require 'time'
|
||||
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"
|
||||
@user_constants << "Date"
|
||||
@user_constants << "DateTime"
|
||||
@user_constants << "Time"
|
||||
@user_constants << "Liquid" # For Liquid::Compile::CompiledContext
|
||||
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 mostly safe for date filters - only neuter methods that could be used
|
||||
# to manipulate system state or sleep/wait.
|
||||
# Keep: now, at, parse, mktime - needed for date filter
|
||||
# Remove: nothing for now - Time is pure computation
|
||||
#
|
||||
# Note: If you want stricter isolation, templates should receive "now" 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
|
||||
# Don't undef __send__ - it causes warnings and is equivalent to send
|
||||
# which we already restrict via public_send
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_object!
|
||||
@box.eval(<<~'RUBY')
|
||||
class Object
|
||||
# Keep public_send - it's safe (only calls public methods) and useful
|
||||
[:gem, :gem_original_require, :require, :require_relative, :load,
|
||||
:display, :define_singleton_method,
|
||||
:instance_variable_set, :remove_instance_variable,
|
||||
:extend, :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
|
||||
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Ruby Compiler
|
||||
#
|
||||
# This module provides the ability to compile Liquid templates to pure Ruby code.
|
||||
# Compiled templates execute in a secure sandbox using Liquid::Box (on Ruby 4.0+).
|
||||
#
|
||||
# ## Usage
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||
# compiled = template.compile_to_ruby
|
||||
#
|
||||
# # Render securely (sandboxed on Ruby 4.0+)
|
||||
# result = compiled.render({ "name" => "World" })
|
||||
# # => "Hello, World!"
|
||||
#
|
||||
# # Access the generated Ruby source
|
||||
# puts compiled.source
|
||||
#
|
||||
# # Check security status
|
||||
# compiled.secure? # => true on Ruby 4.0+
|
||||
#
|
||||
# ## Security
|
||||
#
|
||||
# On Ruby 4.0+, compiled templates execute in a Ruby::Box sandbox that prevents:
|
||||
# - File system access (File, IO, Dir)
|
||||
# - Process control (system, exec, spawn, fork)
|
||||
# - Network access (Socket, Net::HTTP)
|
||||
# - Code loading (require, load, eval)
|
||||
# - Dangerous metaprogramming (define_method, const_set, send)
|
||||
#
|
||||
# On Ruby < 4.0, a polyfill is used that prints a security warning to STDERR.
|
||||
# The polyfill provides NO ACTUAL SECURITY - use Ruby 4.0+ in production.
|
||||
#
|
||||
# ## Performance Benefits
|
||||
#
|
||||
# Compiled templates are ~1.5x faster than interpreted Liquid because:
|
||||
#
|
||||
# 1. **No Context Object**: Variables accessed directly from assigns hash
|
||||
# 2. **No Filter Dispatch**: Filters compiled to direct Ruby calls
|
||||
# 3. **No Resource Limits**: No per-node overhead for limit tracking
|
||||
# 4. **Native Scoping**: Ruby's block scoping instead of manual stacks
|
||||
# 5. **Direct Concatenation**: Output built with << operations
|
||||
# 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
|
||||
#
|
||||
# ## Limitations
|
||||
#
|
||||
# - {% render %} and {% include %} resolved at compile time when possible
|
||||
# - Custom tags need explicit compiler implementations
|
||||
# - Custom filters must 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,349 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# CompiledTemplate represents a compiled Liquid template ready for secure execution.
|
||||
#
|
||||
# This class wraps generated Ruby code and provides a secure execution environment
|
||||
# using Liquid::Box. On Ruby 4.0+, execution happens in a true sandbox. On earlier
|
||||
# versions, a polyfill is used with a security warning.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||
# compiled = template.compile_to_ruby
|
||||
#
|
||||
# # Render with a Liquid::Context (preferred)
|
||||
# context = Liquid::Context.new({ "name" => "World" })
|
||||
# result = compiled.render(context)
|
||||
# # => "Hello, World!"
|
||||
#
|
||||
# # Or render with a simple hash
|
||||
# result = compiled.render({ "name" => "World" })
|
||||
#
|
||||
class CompiledTemplate
|
||||
attr_reader :source, :external_tags
|
||||
attr_accessor :filter_handler
|
||||
|
||||
# @param source [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(source, external_tags = {}, has_external_filters = false)
|
||||
@source = source
|
||||
@external_tags = external_tags
|
||||
@has_external_filters = has_external_filters
|
||||
@filter_handler = nil
|
||||
@proc = nil
|
||||
@box = 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 true if execution will be sandboxed (Ruby 4.0+)
|
||||
def secure?
|
||||
Liquid::Box.secure?
|
||||
end
|
||||
|
||||
# Render the compiled template.
|
||||
#
|
||||
# @param context_or_assigns [Liquid::Context, Hash] A Liquid context or hash of assigns
|
||||
# @param registers [Hash] Registers (only used when passing a Hash)
|
||||
# @param filter_handler [Object] Optional filter handler module
|
||||
# @yield [call_type, *args] Called for external tags/filters only
|
||||
# @return [String] The rendered output
|
||||
#
|
||||
def render(context_or_assigns = {}, registers: {}, filter_handler: nil, &block)
|
||||
compiled_proc = to_proc
|
||||
handler = filter_handler || @filter_handler
|
||||
|
||||
# Accept either a Liquid::Context or a Hash of assigns
|
||||
if context_or_assigns.is_a?(Liquid::Context)
|
||||
liquid_context = context_or_assigns
|
||||
assigns = extract_assigns(liquid_context)
|
||||
file_system = liquid_context.registers[:file_system]
|
||||
|
||||
# Build external handler that handles include/render internally
|
||||
external_handler = build_external_handler(liquid_context, file_system, handler, &block)
|
||||
|
||||
# Use a wrapper context that delegates to the Liquid::Context
|
||||
context = ContextWrapper.new(liquid_context)
|
||||
else
|
||||
assigns = context_or_assigns
|
||||
file_system = registers[:file_system]
|
||||
|
||||
# Create a minimal context for Drop support
|
||||
context = CompiledContext.new(assigns, registers: registers)
|
||||
|
||||
# Build external handler
|
||||
external_handler = build_external_handler(nil, file_system, handler, &block)
|
||||
end
|
||||
|
||||
compiled_proc.call(assigns, context, external_handler)
|
||||
end
|
||||
|
||||
alias call render
|
||||
|
||||
def code
|
||||
@source
|
||||
end
|
||||
|
||||
def to_s
|
||||
@source
|
||||
end
|
||||
|
||||
def to_proc
|
||||
@proc ||= compile_to_proc
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_assigns(liquid_context)
|
||||
# Get the first environment (static_environments)
|
||||
liquid_context.environments.first || {}
|
||||
end
|
||||
|
||||
# Build the external call handler
|
||||
# Handles :include and :render internally using file_system
|
||||
# Yields to block for :tag and :filter if block given
|
||||
def build_external_handler(liquid_context, file_system, filter_handler, &block)
|
||||
external_tags = @external_tags
|
||||
|
||||
->(call_type, *args) do
|
||||
case call_type
|
||||
when :include
|
||||
handle_include(liquid_context, file_system, *args)
|
||||
|
||||
when :render
|
||||
handle_render(liquid_context, file_system, *args)
|
||||
|
||||
when :tag
|
||||
if block
|
||||
block.call(call_type, *args)
|
||||
else
|
||||
handle_tag(liquid_context, external_tags, *args)
|
||||
end
|
||||
|
||||
when :filter
|
||||
if block
|
||||
block.call(call_type, *args)
|
||||
else
|
||||
handle_filter(liquid_context, filter_handler, *args)
|
||||
end
|
||||
|
||||
else
|
||||
if block
|
||||
block.call(call_type, *args)
|
||||
else
|
||||
raise ArgumentError, "Unknown external call type: #{call_type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_include(liquid_context, file_system, template_name, variable, attrs, alias_name, assigns, context)
|
||||
raise Liquid::FileSystemError, "Could not find asset #{template_name}" unless file_system
|
||||
|
||||
snippet_source = file_system.read_template_file(template_name)
|
||||
snippet = Liquid::Template.parse(snippet_source, line_numbers: true)
|
||||
snippet.name = template_name
|
||||
|
||||
# Include shares scope with parent
|
||||
if liquid_context
|
||||
# Set attributes in context
|
||||
attrs&.each { |k, v| liquid_context[k] = v }
|
||||
|
||||
context_var_name = alias_name || template_name.to_s.split('/').last
|
||||
if variable
|
||||
if variable.is_a?(Array)
|
||||
return variable.map do |item|
|
||||
liquid_context[context_var_name] = item
|
||||
snippet.render(liquid_context)
|
||||
end.join
|
||||
else
|
||||
liquid_context[context_var_name] = variable
|
||||
end
|
||||
end
|
||||
|
||||
snippet.render(liquid_context)
|
||||
else
|
||||
# No liquid context - just use assigns
|
||||
render_assigns = assigns.merge(attrs || {})
|
||||
snippet.render(render_assigns)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_render(liquid_context, file_system, template_name, variable, attrs, alias_name, is_for_loop, context)
|
||||
raise Liquid::FileSystemError, "Could not find asset #{template_name}" unless file_system
|
||||
|
||||
snippet_source = file_system.read_template_file(template_name)
|
||||
snippet = Liquid::Template.parse(snippet_source, line_numbers: true)
|
||||
snippet.name = template_name
|
||||
|
||||
# Render creates isolated scope - only attrs are passed
|
||||
render_assigns = attrs&.dup || {}
|
||||
context_var_name = alias_name || template_name.to_s.split('/').last.sub(/\.liquid$/, '')
|
||||
|
||||
if variable
|
||||
if is_for_loop && variable.is_a?(Array)
|
||||
return variable.map do |item|
|
||||
render_assigns[context_var_name] = item
|
||||
if liquid_context
|
||||
isolated_ctx = Liquid::Context.build(
|
||||
static_environments: render_assigns,
|
||||
registers: liquid_context.registers,
|
||||
rethrow_errors: false,
|
||||
)
|
||||
isolated_ctx.exception_renderer = liquid_context.exception_renderer
|
||||
snippet.render(isolated_ctx)
|
||||
else
|
||||
snippet.render(render_assigns)
|
||||
end
|
||||
end.join
|
||||
else
|
||||
render_assigns[context_var_name] = variable
|
||||
end
|
||||
end
|
||||
|
||||
if liquid_context
|
||||
isolated_ctx = Liquid::Context.build(
|
||||
static_environments: render_assigns,
|
||||
registers: liquid_context.registers,
|
||||
rethrow_errors: false,
|
||||
)
|
||||
isolated_ctx.exception_renderer = liquid_context.exception_renderer
|
||||
snippet.render(isolated_ctx)
|
||||
else
|
||||
snippet.render(render_assigns)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_tag(liquid_context, external_tags, tag_var, tag_assigns)
|
||||
tag = external_tags[tag_var]
|
||||
return '' unless tag
|
||||
|
||||
if liquid_context
|
||||
output = +''
|
||||
tag.render_to_output_buffer(liquid_context, output)
|
||||
output
|
||||
else
|
||||
# Create a minimal context
|
||||
ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)
|
||||
output = +''
|
||||
tag.render_to_output_buffer(ctx, output)
|
||||
output
|
||||
end
|
||||
end
|
||||
|
||||
def handle_filter(liquid_context, filter_handler, filter_name, input, *filter_args)
|
||||
# Try filter handler first
|
||||
if filter_handler&.respond_to?(filter_name)
|
||||
return filter_handler.public_send(filter_name, input, *filter_args)
|
||||
end
|
||||
|
||||
# Try liquid context's strainer
|
||||
if liquid_context
|
||||
strainer = liquid_context.strainer
|
||||
if strainer.class.invokable?(filter_name)
|
||||
return strainer.invoke(filter_name, input, *filter_args)
|
||||
end
|
||||
end
|
||||
|
||||
# Return input unchanged if filter not found
|
||||
input
|
||||
end
|
||||
|
||||
def compile_to_proc
|
||||
if Liquid::Box.secure?
|
||||
compile_in_sandbox
|
||||
else
|
||||
compile_insecure
|
||||
end
|
||||
end
|
||||
|
||||
def compile_in_sandbox
|
||||
@box ||= begin
|
||||
box = Liquid::Box.new
|
||||
box.load_liquid_runtime!
|
||||
box.lock!
|
||||
box
|
||||
end
|
||||
|
||||
template_class_name = "CompiledTemplate_#{object_id}"
|
||||
class_code = <<~RUBY
|
||||
class #{template_class_name}
|
||||
TEMPLATE_PROC = #{@source}
|
||||
|
||||
def self.render(*args, &block)
|
||||
TEMPLATE_PROC.call(*args, &block)
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
|
||||
@box.eval(class_code)
|
||||
template_class = @box[template_class_name]
|
||||
|
||||
->(assigns, context, external_handler) do
|
||||
template_class.render(assigns, context, external_handler)
|
||||
end
|
||||
end
|
||||
|
||||
def compile_insecure
|
||||
warn_once_insecure unless Liquid::Box.secure?
|
||||
|
||||
require_relative 'runtime' unless defined?(::LR)
|
||||
|
||||
# rubocop:disable Security/Eval
|
||||
eval(@source)
|
||||
# rubocop:enable Security/Eval
|
||||
end
|
||||
|
||||
def warn_once_insecure
|
||||
return if @warned_insecure
|
||||
|
||||
@warned_insecure = true
|
||||
warn "[SECURITY WARNING] Liquid compiled template running outside of Ruby::Box sandbox. " \
|
||||
"On Ruby 4.0+, this runs in a secure sandbox. On earlier versions, be cautious " \
|
||||
"about running untrusted templates."
|
||||
end
|
||||
|
||||
# Wrapper around Liquid::Context for compiled template compatibility
|
||||
class ContextWrapper
|
||||
def initialize(liquid_context)
|
||||
@liquid_context = liquid_context
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@liquid_context[key]
|
||||
end
|
||||
|
||||
def []=(key, value)
|
||||
@liquid_context[key] = value
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
@liquid_context.key?(key)
|
||||
end
|
||||
|
||||
def registers
|
||||
@liquid_context.registers
|
||||
end
|
||||
|
||||
def strainer
|
||||
@liquid_context.strainer
|
||||
end
|
||||
|
||||
def handle_error(e, line_number = nil)
|
||||
@liquid_context.handle_error(e, line_number)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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
|
||||
# Inline: Liquid truthiness is "not nil and not false"
|
||||
if op.nil?
|
||||
left_expr = ExpressionCompiler.compile(left, compiler)
|
||||
# For simple variable access, we can use a more compact form
|
||||
# Complex expressions need temp variable to avoid double evaluation
|
||||
if simple_expression?(left)
|
||||
return "(#{left_expr} != nil && #{left_expr} != false)"
|
||||
else
|
||||
return "((__v__ = #{left_expr}) != nil && __v__ != false)"
|
||||
end
|
||||
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
|
||||
|
||||
# Check if an expression is simple (doesn't need temp variable to avoid double evaluation)
|
||||
def self.simple_expression?(expr)
|
||||
case expr
|
||||
when nil, true, false, Integer, Float, String
|
||||
true
|
||||
when VariableLookup
|
||||
# Simple variable or property access is safe to evaluate twice
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
# 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
|
||||
|
||||
# Check for forloop property inlining
|
||||
if name == 'forloop' && lookup.lookups.length == 1
|
||||
loop_ctx = compiler.current_loop_context
|
||||
if loop_ctx && loop_ctx[:idx_var]
|
||||
inlined = compile_forloop_property(lookup.lookups.first, loop_ctx)
|
||||
return inlined if inlined
|
||||
end
|
||||
end
|
||||
|
||||
# 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 = "LR.lookup(#{base}, #{compile(key, compiler)}, __context__)"
|
||||
elsif key.is_a?(Integer)
|
||||
# Numeric index like foo[0]
|
||||
base = "LR.lookup(#{base}, #{key}, __context__)"
|
||||
elsif key.is_a?(String)
|
||||
# Always use LR.lookup which tries key access first,
|
||||
# then falls back to method call for command methods (first, last, size)
|
||||
base = "LR.lookup(#{base}, #{key.inspect}, __context__)"
|
||||
else
|
||||
base = "LR.lookup(#{base}, #{compile(key, compiler)}, __context__)"
|
||||
end
|
||||
end
|
||||
|
||||
base
|
||||
end
|
||||
|
||||
# Inline forloop property access to avoid hash allocation
|
||||
# @param prop [String] Property name (index, index0, first, last, etc.)
|
||||
# @param loop_ctx [Hash] Loop context with idx_var, len_var, loop_name
|
||||
# @return [String, nil] Inlined Ruby code or nil if can't inline
|
||||
def self.compile_forloop_property(prop, loop_ctx)
|
||||
idx = loop_ctx[:idx_var]
|
||||
len = loop_ctx[:len_var]
|
||||
name = loop_ctx[:loop_name]
|
||||
|
||||
case prop
|
||||
when 'index'
|
||||
"(#{idx} + 1)"
|
||||
when 'index0'
|
||||
idx
|
||||
when 'rindex'
|
||||
"(#{len} - #{idx})"
|
||||
when 'rindex0'
|
||||
"(#{len} - #{idx} - 1)"
|
||||
when 'first'
|
||||
"(#{idx} == 0)"
|
||||
when 'last'
|
||||
"(#{idx} == #{len} - 1)"
|
||||
when 'length'
|
||||
len
|
||||
when 'name'
|
||||
name ? name.inspect : "nil"
|
||||
else
|
||||
nil # Unknown property, fall back to hash lookup
|
||||
end
|
||||
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
|
||||
"(LR.to_integer(#{start_expr})...LR.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
|
||||
@@ -0,0 +1,230 @@
|
||||
# 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) { "LR.to_s(#{input}).downcase" },
|
||||
'upcase' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).upcase" },
|
||||
'capitalize' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).capitalize" },
|
||||
'strip' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).strip" },
|
||||
'lstrip' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).lstrip" },
|
||||
'rstrip' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).rstrip" },
|
||||
'reverse' => ->(input, _args, _kwargs, _compiler) { "(#{input}.is_a?(Array) ? #{input}.reverse : LR.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) { "LR.to_number(#{input}).abs" },
|
||||
'ceil' => ->(input, _args, _kwargs, _compiler) { "LR.to_number(#{input}).ceil.to_i" },
|
||||
'floor' => ->(input, _args, _kwargs, _compiler) { "LR.to_number(#{input}).floor.to_i" },
|
||||
'escape' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.escape_html(#{input}))" },
|
||||
'h' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.escape_html(#{input}))" },
|
||||
'url_encode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.url_encode(#{input}))" },
|
||||
'url_decode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.url_decode(#{input}))" },
|
||||
'base64_encode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_encode(#{input})" },
|
||||
'base64_decode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_decode(#{input})" },
|
||||
'base64_url_safe_encode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_url_safe_encode(#{input})" },
|
||||
'base64_url_safe_decode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_url_safe_decode(#{input})" },
|
||||
'strip_html' => ->(input, _args, _kwargs, _compiler) { "LR.strip_html(#{input})" },
|
||||
'strip_newlines' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).gsub(/\\r?\\n/, '')" },
|
||||
'newline_to_br' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).gsub(/\\r?\\n/, \"<br />\\n\")" },
|
||||
}.freeze
|
||||
|
||||
# Filters with arguments that need special handling
|
||||
# All use LR.method() calls to pre-loaded runtime
|
||||
PARAMETERIZED_FILTERS = {
|
||||
'append' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"LR.to_s(#{input}) + LR.to_s(#{arg})"
|
||||
},
|
||||
'prepend' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"LR.to_s(#{arg}) + LR.to_s(#{input})"
|
||||
},
|
||||
'plus' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(LR.to_number(#{input}) + LR.to_number(#{arg}))"
|
||||
},
|
||||
'minus' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(LR.to_number(#{input}) - LR.to_number(#{arg}))"
|
||||
},
|
||||
'times' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(LR.to_number(#{input}) * LR.to_number(#{arg}))"
|
||||
},
|
||||
'divided_by' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(LR.to_number(#{input}) / LR.to_number(#{arg}))"
|
||||
},
|
||||
'modulo' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(LR.to_number(#{input}) % LR.to_number(#{arg}))"
|
||||
},
|
||||
'round' => ->(input, args, _kwargs, compiler) {
|
||||
if args.empty?
|
||||
"LR.to_number(#{input}).round.to_i"
|
||||
else
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"LR.to_number(#{input}).round(LR.to_number(#{arg}))"
|
||||
end
|
||||
},
|
||||
'at_least' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"[LR.to_number(#{input}), LR.to_number(#{arg})].max"
|
||||
},
|
||||
'at_most' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"[LR.to_number(#{input}), LR.to_number(#{arg})].min"
|
||||
},
|
||||
'default' => ->(input, args, kwargs, compiler) {
|
||||
default_val = args.empty? ? "''" : compile_arg(args[0], compiler)
|
||||
allow_false = kwargs && kwargs['allow_false'] ? "allow_false: #{compile_arg(kwargs['allow_false'], compiler)}" : ''
|
||||
"LR.default(#{input}, #{default_val}#{allow_false.empty? ? '' : ', ' + allow_false})"
|
||||
},
|
||||
'split' => ->(input, args, _kwargs, compiler) {
|
||||
pattern = args.empty? ? "' '" : compile_arg(args[0], compiler)
|
||||
"LR.to_s(#{input}).split(LR.to_s(#{pattern}))"
|
||||
},
|
||||
'join' => ->(input, args, _kwargs, compiler) {
|
||||
glue = args.empty? ? "' '" : compile_arg(args[0], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input}.map { |i| LR.to_s(i) }.join(LR.to_s(#{glue})) : LR.to_s(#{input}))"
|
||||
},
|
||||
'replace' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
|
||||
"LR.to_s(#{input}).gsub(LR.to_s(#{string}), LR.to_s(#{replacement}))"
|
||||
},
|
||||
'replace_first' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
|
||||
"LR.to_s(#{input}).sub(LR.to_s(#{string}), LR.to_s(#{replacement}))"
|
||||
},
|
||||
'remove' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
"LR.to_s(#{input}).gsub(LR.to_s(#{string}), '')"
|
||||
},
|
||||
'remove_first' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
"LR.to_s(#{input}).sub(LR.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) : "'...'"
|
||||
"LR.truncate(#{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) : "'...'"
|
||||
"LR.truncatewords(#{input}, #{words}, #{ellipsis})"
|
||||
},
|
||||
'slice' => ->(input, args, _kwargs, compiler) {
|
||||
offset = compile_arg(args[0], compiler)
|
||||
length = args.length > 1 ? compile_arg(args[1], compiler) : "1"
|
||||
"LR.slice(#{input}, #{offset}, #{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?(:[]) && LR.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?(:[]) && LR.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)
|
||||
"LR.date(#{input}, #{format})"
|
||||
},
|
||||
'escape_once' => ->(input, _args, _kwargs, _compiler) {
|
||||
"LR.escape_once(#{input})"
|
||||
},
|
||||
}.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
|
||||
# Yields [:filter, name, input, args] to the external handler
|
||||
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(', ')}]"
|
||||
|
||||
# Yield to the external handler
|
||||
"__external__.call(:filter, #{name.inspect}, #{input}, #{args_str})"
|
||||
end
|
||||
|
||||
# Compile a filter argument
|
||||
def self.compile_arg(arg, compiler)
|
||||
ExpressionCompiler.compile(arg, compiler)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,418 @@
|
||||
# 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
|
||||
@loop_context_stack = [] # Stack of loop contexts for break/continue
|
||||
end
|
||||
|
||||
# Push a loop context onto the stack (for nested loops)
|
||||
# @param break_var [String, nil] Variable name for break flag, or nil if no break
|
||||
# @param idx_var [String, nil] Variable name for loop index
|
||||
# @param len_var [String, nil] Variable name for collection length
|
||||
# @param loop_name [String, nil] Name of the loop (for forloop.name)
|
||||
def push_loop_context(break_var: nil, idx_var: nil, len_var: nil, loop_name: nil)
|
||||
@loop_context_stack.push({
|
||||
break_var: break_var,
|
||||
idx_var: idx_var,
|
||||
len_var: len_var,
|
||||
loop_name: loop_name
|
||||
})
|
||||
end
|
||||
|
||||
# Pop the current loop context
|
||||
def pop_loop_context
|
||||
@loop_context_stack.pop
|
||||
end
|
||||
|
||||
# Get the current loop context (for break/continue compilation)
|
||||
# @return [Hash, nil] Current loop context or nil if not in a loop
|
||||
def current_loop_context
|
||||
@loop_context_stack.last
|
||||
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)
|
||||
|
||||
# Lambda signature: (assigns, context, external_handler)
|
||||
# - assigns: Hash of template variables
|
||||
# - context: CompiledContext for Drop support
|
||||
# - external_handler: Proc that handles [:tag, ...] and [:filter, ...] calls
|
||||
code.line "->(assigns, __context__, __external__) do"
|
||||
|
||||
code.indent do
|
||||
# Initialize the output buffer
|
||||
code.line '__output__ = +""'
|
||||
code.blank_line
|
||||
|
||||
# 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 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)
|
||||
# First, check if the tag implements to_ruby (custom compilation)
|
||||
if tag.respond_to?(:to_ruby)
|
||||
tag.to_ruby(code, self)
|
||||
# Then check for a built-in compiler class
|
||||
elsif (compiler_class = find_tag_compiler(tag))
|
||||
compiler_class.compile(tag, self, code)
|
||||
else
|
||||
# Unknown tag - yield to caller 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} (yields to caller)"
|
||||
end
|
||||
# Yield [:tag, tag_var, assigns] to the external handler
|
||||
code.line "__output__ << __external__.call(:tag, #{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
|
||||
|
||||
# NOTE: compile_helper_methods was removed - helpers are now provided by the
|
||||
# pre-loaded LR module (compile/runtime.rb). Templates call LR.to_s(), LR.lookup(), etc.
|
||||
end
|
||||
|
||||
# Custom error for compilation issues
|
||||
class CompileError < StandardError; end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,252 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Compiled Template Runtime
|
||||
#
|
||||
# This module provides ALL helpers for compiled Liquid templates.
|
||||
# It is loaded into the sandbox BEFORE lock!, so these methods
|
||||
# are available to all compiled templates.
|
||||
#
|
||||
# Templates reference these as LR.method_name(args)
|
||||
#
|
||||
# == Security Note
|
||||
#
|
||||
# It is SAFE to expose side-effect-free, non-IO methods that don't leak
|
||||
# objects with dangerous methods. For example:
|
||||
#
|
||||
# - CGI.escapeHTML(str) -> returns a String (safe)
|
||||
# - Base64.strict_encode64(str) -> returns a String (safe)
|
||||
# - BigDecimal(str) -> returns a Numeric (safe)
|
||||
#
|
||||
# These are pure functions that take values and return values.
|
||||
# They don't provide any capability to escape the sandbox.
|
||||
#
|
||||
# We capture METHOD REFERENCES (e.g., CGI.method(:escapeHTML)) before the
|
||||
# sandbox locks. This means templates can use the functionality without
|
||||
# having direct access to the CGI, Base64, or BigDecimal constants.
|
||||
#
|
||||
# Dependencies (CGI, Base64, BigDecimal) are loaded by box.rb into the
|
||||
# sandbox BEFORE this file runs. The constants are preserved after lock.
|
||||
|
||||
module LR
|
||||
# === Type Conversion ===
|
||||
|
||||
# Convert to string like Liquid does
|
||||
def self.to_s(obj)
|
||||
case obj
|
||||
when nil then ''
|
||||
when Array then obj.join
|
||||
else obj.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Convert to number for arithmetic
|
||||
# Use Kernel.BigDecimal to access from root namespace
|
||||
def self.to_number(obj)
|
||||
case obj
|
||||
when Numeric then obj
|
||||
when String
|
||||
if obj.strip =~ /\A-?\d+\.\d+\z/
|
||||
Kernel.BigDecimal(obj)
|
||||
else
|
||||
obj.to_i
|
||||
end
|
||||
else 0
|
||||
end
|
||||
end
|
||||
|
||||
# Convert to integer
|
||||
def self.to_integer(obj)
|
||||
return obj if obj.is_a?(Integer)
|
||||
Integer(obj.to_s) rescue 0
|
||||
end
|
||||
|
||||
# === Truthiness ===
|
||||
|
||||
# Liquid truthiness: only nil and false are falsy
|
||||
def self.truthy?(obj)
|
||||
obj != nil && obj != false
|
||||
end
|
||||
|
||||
# === Output ===
|
||||
|
||||
# Output a value, converting to string safely (handles arrays recursively)
|
||||
def self.output(obj)
|
||||
case obj
|
||||
when nil then ''
|
||||
when Array then obj.map { |o| output(o) }.join
|
||||
when BigDecimal
|
||||
# Format BigDecimal like Liquid does - avoid scientific notation
|
||||
obj.to_s('F')
|
||||
else obj.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# === Lookup ===
|
||||
|
||||
# Variable lookup - handles hash/array access, method calls, to_liquid, and drop context
|
||||
def self.lookup(obj, key, context = nil)
|
||||
return nil if obj.nil?
|
||||
|
||||
# Set context on Drops BEFORE accessing their methods
|
||||
obj = obj.to_liquid if obj.respond_to?(:to_liquid)
|
||||
obj.context = context if context && obj.respond_to?(:context=)
|
||||
|
||||
# Perform the lookup
|
||||
result = if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))
|
||||
obj[key]
|
||||
elsif obj.respond_to?(key)
|
||||
obj.public_send(key)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
# Convert result to liquid and set context for nested Drops
|
||||
result = result.to_liquid if result.respond_to?(:to_liquid)
|
||||
result.context = context if context && result.respond_to?(:context=)
|
||||
result
|
||||
end
|
||||
|
||||
# === HTML/URL Encoding ===
|
||||
# Capture method references before sandbox locks - only the specific methods we need
|
||||
CGI_ESCAPE_HTML = CGI.method(:escapeHTML)
|
||||
CGI_ESCAPE = CGI.method(:escape)
|
||||
CGI_UNESCAPE = CGI.method(:unescape)
|
||||
|
||||
HTML_ESCAPE_MAP = { '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''' }.freeze
|
||||
|
||||
def self.escape_html(obj)
|
||||
CGI_ESCAPE_HTML.call(to_s(obj))
|
||||
end
|
||||
|
||||
def self.url_encode(obj)
|
||||
CGI_ESCAPE.call(to_s(obj))
|
||||
end
|
||||
|
||||
def self.url_decode(obj)
|
||||
CGI_UNESCAPE.call(to_s(obj))
|
||||
end
|
||||
|
||||
def self.escape_once(obj)
|
||||
# Only escape if not already escaped
|
||||
to_s(obj).gsub(/["><']|&(?!([a-zA-Z]+|(#\d+));)/, HTML_ESCAPE_MAP)
|
||||
end
|
||||
|
||||
# === Base64 ===
|
||||
# Capture method references before sandbox locks
|
||||
BASE64_ENCODE = Base64.method(:strict_encode64)
|
||||
BASE64_DECODE = Base64.method(:strict_decode64)
|
||||
BASE64_URL_ENCODE = Base64.method(:urlsafe_encode64)
|
||||
BASE64_URL_DECODE = Base64.method(:urlsafe_decode64)
|
||||
|
||||
def self.base64_encode(obj)
|
||||
BASE64_ENCODE.call(to_s(obj))
|
||||
end
|
||||
|
||||
def self.base64_decode(obj)
|
||||
BASE64_DECODE.call(to_s(obj))
|
||||
end
|
||||
|
||||
def self.base64_url_safe_encode(obj)
|
||||
BASE64_URL_ENCODE.call(to_s(obj))
|
||||
end
|
||||
|
||||
def self.base64_url_safe_decode(obj)
|
||||
BASE64_URL_DECODE.call(to_s(obj))
|
||||
end
|
||||
|
||||
# === String Manipulation ===
|
||||
|
||||
def self.strip_html(obj)
|
||||
to_s(obj).gsub(%r{<script.*?</script>|<!--.*?-->|<style.*?</style>}m, '').gsub(/<.*?>/m, '')
|
||||
end
|
||||
|
||||
# Truncate string to length with ellipsis
|
||||
def self.truncate(input, length = 50, ellipsis = '...')
|
||||
str = to_s(input)
|
||||
ell_str = to_s(ellipsis)
|
||||
len = length.to_i
|
||||
l = [len - ell_str.length, 0].max
|
||||
str.length > len ? str[0, l] + ell_str : str
|
||||
end
|
||||
|
||||
# Truncate to word count
|
||||
def self.truncatewords(input, num_words = 15, ellipsis = '...')
|
||||
max_words = [num_words.to_i, 1].max
|
||||
words = to_s(input).split(' ', max_words + 1)
|
||||
if words.length > max_words
|
||||
words[0, max_words].join(' ') + to_s(ellipsis)
|
||||
else
|
||||
input.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# === Date Formatting ===
|
||||
|
||||
def self.date(input, format)
|
||||
return input if format.to_s.empty?
|
||||
d = case input
|
||||
when Time, Date 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(format.to_s) : input
|
||||
end
|
||||
|
||||
# === Collection Helpers ===
|
||||
|
||||
# Convert to array for iteration - returns Array or empty Array
|
||||
# This guarantees the result supports [], .length, .empty? without respond_to? checks
|
||||
def self.to_array(collection)
|
||||
case collection
|
||||
when Array then collection
|
||||
when Range then collection.to_a
|
||||
when nil then EMPTY_ARRAY
|
||||
else
|
||||
collection.respond_to?(:to_a) ? collection.to_a : EMPTY_ARRAY
|
||||
end
|
||||
end
|
||||
|
||||
# Frozen empty array to avoid allocations
|
||||
EMPTY_ARRAY = [].freeze
|
||||
|
||||
# Iterate safely, handling ranges and non-iterables
|
||||
def self.iterate(collection)
|
||||
case collection
|
||||
when Range then collection.to_a
|
||||
when nil then EMPTY_ARRAY
|
||||
else collection.respond_to?(:each) ? collection : EMPTY_ARRAY
|
||||
end
|
||||
end
|
||||
|
||||
# Get collection length safely
|
||||
def self.size(collection)
|
||||
collection.respond_to?(:size) ? collection.size : 0
|
||||
end
|
||||
|
||||
# === Default Filter ===
|
||||
|
||||
def self.default(input, default_value, allow_false: false)
|
||||
if allow_false
|
||||
(input.nil? || (input.respond_to?(:empty?) && input.empty?)) ? default_value : input
|
||||
else
|
||||
(!truthy?(input) || (input.respond_to?(:empty?) && input.empty?)) ? default_value : input
|
||||
end
|
||||
end
|
||||
|
||||
# === Array/String Slice ===
|
||||
|
||||
def self.slice(input, offset, length = 1)
|
||||
off = to_integer(offset)
|
||||
len = to_integer(length)
|
||||
if input.is_a?(Array)
|
||||
input.slice(off, len) || []
|
||||
else
|
||||
to_s(input).slice(off, len) || ''
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Alias for backwards compatibility
|
||||
LiquidRuntime = LR
|
||||
@@ -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
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% break %} tags
|
||||
#
|
||||
# Break is implemented with a flag variable that's checked in the while condition.
|
||||
# This avoids catch/throw overhead entirely.
|
||||
#
|
||||
# Generated code sets the break flag and uses `next` to exit the current iteration.
|
||||
# The while loop condition checks the flag and exits if set.
|
||||
class BreakCompiler
|
||||
def self.compile(_tag, compiler, code)
|
||||
loop_ctx = compiler.current_loop_context
|
||||
if loop_ctx && loop_ctx[:break_var]
|
||||
# Set the break flag and exit this iteration
|
||||
code.line "#{loop_ctx[:break_var]} = true"
|
||||
code.line "next"
|
||||
else
|
||||
# Fallback: shouldn't happen if contains_tag? works correctly
|
||||
code.line "break"
|
||||
end
|
||||
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
|
||||
@@ -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,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% continue %} tags
|
||||
#
|
||||
# Continue is implemented with Ruby's native `next` statement.
|
||||
# Since we use a while loop (not each), `next` correctly skips
|
||||
# to the next iteration, but we must increment the index first.
|
||||
class ContinueCompiler
|
||||
def self.compile(_tag, compiler, code)
|
||||
# Get the index variable from the loop context
|
||||
loop_ctx = compiler.current_loop_context
|
||||
if loop_ctx && loop_ctx[:idx_var]
|
||||
# Increment index before next, otherwise we'd infinite loop
|
||||
code.line "#{loop_ctx[:idx_var]} += 1"
|
||||
end
|
||||
code.line "next"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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__ << LR.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
|
||||
@@ -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
|
||||
@@ -0,0 +1,339 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'set'
|
||||
|
||||
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 %}
|
||||
#
|
||||
# Optimizations:
|
||||
# - Detects break/continue usage at compile time
|
||||
# - Uses while loop with index for minimal overhead
|
||||
# - Break implemented with flag variable (no catch/throw)
|
||||
# - Continue implemented with next (native Ruby)
|
||||
# - Avoids Hash allocation for forloop when not used
|
||||
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 and convert to array for indexed access
|
||||
# After this, coll_var is guaranteed to be an Array (or nil)
|
||||
code.line "#{coll_var} = #{collection_expr}"
|
||||
code.line "#{coll_var} = LR.to_array(#{coll_var})"
|
||||
|
||||
# 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}.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
|
||||
code.line "if #{coll_var} && !#{coll_var}.empty?"
|
||||
code.indent do
|
||||
compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
|
||||
end
|
||||
code.line "end"
|
||||
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}[LR.to_integer(#{from_expr}), LR.to_integer(#{limit_expr})] || []"
|
||||
else
|
||||
code.line "#{coll_var} = #{coll_var}.drop(LR.to_integer(#{from_expr}))"
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
|
||||
# Analyze loop body for break/continue usage and forloop access
|
||||
has_break = contains_tag?(for_block, Break)
|
||||
forloop_props = detect_forloop_properties(for_block)
|
||||
uses_forloop = !forloop_props.empty?
|
||||
|
||||
# Calculate length (needed for forloop or bounds checking)
|
||||
code.line "#{len_var} = #{coll_var}.length"
|
||||
code.line "#{idx_var} = 0"
|
||||
|
||||
# Break uses a flag variable - no catch/throw overhead
|
||||
if has_break
|
||||
break_var = compiler.generate_var_name("brk")
|
||||
code.line "#{break_var} = false"
|
||||
code.line "while #{idx_var} < #{len_var} && !#{break_var}"
|
||||
else
|
||||
code.line "while #{idx_var} < #{len_var}"
|
||||
end
|
||||
|
||||
# Check if all forloop properties can be inlined (no hash needed)
|
||||
inlinable_props = %w[index index0 rindex rindex0 first last length name]
|
||||
needs_forloop_hash = uses_forloop && !forloop_props.all? { |p| inlinable_props.include?(p) }
|
||||
|
||||
code.indent do
|
||||
# Set the loop variable directly from array index
|
||||
code.line "assigns[#{var_name.inspect}] = #{coll_var}[#{idx_var}]"
|
||||
|
||||
# Only create forloop hash if we have properties that can't be inlined
|
||||
if needs_forloop_hash
|
||||
compile_forloop_hash(tag, idx_var, len_var, code)
|
||||
end
|
||||
|
||||
# Compile the loop body
|
||||
# The BreakCompiler/ContinueCompiler will emit the right code
|
||||
# based on the context we pass through the compiler
|
||||
compiler.push_loop_context(
|
||||
break_var: has_break ? break_var : nil,
|
||||
idx_var: idx_var,
|
||||
len_var: len_var,
|
||||
loop_name: tag.instance_variable_get(:@name)
|
||||
)
|
||||
BlockBodyCompiler.compile(for_block, compiler, code)
|
||||
compiler.pop_loop_context
|
||||
|
||||
# Increment index
|
||||
code.line "#{idx_var} += 1"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Clean up
|
||||
code.line "assigns.delete(#{var_name.inspect})"
|
||||
code.line "assigns.delete('forloop')" if needs_forloop_hash
|
||||
end
|
||||
|
||||
def self.compile_forloop_hash(tag, idx_var, len_var, code)
|
||||
loop_name = tag.instance_variable_get(:@name)
|
||||
code.line "assigns['forloop'] = {"
|
||||
code.indent do
|
||||
code.line "'name' => #{loop_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 "}"
|
||||
end
|
||||
|
||||
# Check if a block body contains a specific tag type (recursively)
|
||||
def self.contains_tag?(body, tag_class)
|
||||
return false if body.nil?
|
||||
nodelist = body.nodelist
|
||||
return false if nodelist.nil?
|
||||
|
||||
nodelist.any? do |node|
|
||||
case node
|
||||
when tag_class
|
||||
true
|
||||
when Block
|
||||
# Check nested blocks (if, for, case, etc.)
|
||||
contains_tag?(node.instance_variable_get(:@body), tag_class) ||
|
||||
(node.respond_to?(:nodelist) && contains_tag_in_nodelist?(node.nodelist, tag_class))
|
||||
when Tag
|
||||
# Tags with blocks
|
||||
check_tag_for_nested(node, tag_class)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.check_tag_for_nested(tag, tag_class)
|
||||
# Check various block-holding tags
|
||||
[:@for_block, :@else_block, :@body, :@consequent, :@alternative].each do |ivar|
|
||||
if tag.instance_variable_defined?(ivar)
|
||||
block = tag.instance_variable_get(ivar)
|
||||
return true if contains_tag?(block, tag_class)
|
||||
end
|
||||
end
|
||||
|
||||
# Check If tag's blocks array
|
||||
if tag.respond_to?(:blocks)
|
||||
tag.blocks.each do |block|
|
||||
if block.respond_to?(:attachment)
|
||||
return true if contains_tag?(block.attachment, tag_class)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def self.contains_tag_in_nodelist?(nodelist, tag_class)
|
||||
return false if nodelist.nil?
|
||||
nodelist.any? { |n| n.is_a?(tag_class) || (n.is_a?(Tag) && check_tag_for_nested(n, tag_class)) }
|
||||
end
|
||||
|
||||
# Check if the loop body accesses forloop variable
|
||||
def self.uses_forloop_var?(body)
|
||||
return false if body.nil?
|
||||
nodelist = body.nodelist
|
||||
return false if nodelist.nil?
|
||||
|
||||
nodelist.any? do |node|
|
||||
case node
|
||||
when Variable
|
||||
# Check if variable references forloop
|
||||
lookup = node.name
|
||||
if lookup.is_a?(VariableLookup)
|
||||
return true if lookup.name == 'forloop'
|
||||
end
|
||||
false
|
||||
when Tag
|
||||
# Recursively check tag bodies and conditions
|
||||
check_tag_for_forloop(node)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.check_tag_for_forloop(tag)
|
||||
# Check block bodies
|
||||
[:@for_block, :@else_block, :@body, :@consequent, :@alternative].each do |ivar|
|
||||
if tag.instance_variable_defined?(ivar)
|
||||
block = tag.instance_variable_get(ivar)
|
||||
return true if uses_forloop_var?(block)
|
||||
end
|
||||
end
|
||||
|
||||
# Check If/Unless/Case conditions
|
||||
if tag.respond_to?(:blocks)
|
||||
tag.blocks.each do |block|
|
||||
# Check condition expressions
|
||||
if block.respond_to?(:left) && variable_references_forloop?(block.left)
|
||||
return true
|
||||
end
|
||||
if block.respond_to?(:right) && variable_references_forloop?(block.right)
|
||||
return true
|
||||
end
|
||||
# Check block attachment (body)
|
||||
if block.respond_to?(:attachment)
|
||||
return true if uses_forloop_var?(block.attachment)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Check if an expression references forloop variable
|
||||
def self.variable_references_forloop?(expr)
|
||||
case expr
|
||||
when VariableLookup
|
||||
expr.name == 'forloop'
|
||||
when Variable
|
||||
expr.name.is_a?(VariableLookup) && expr.name.name == 'forloop'
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Detect which forloop properties are used (for potential future optimization)
|
||||
# Returns Set of property names like 'index', 'first', 'last', etc.
|
||||
def self.detect_forloop_properties(body)
|
||||
props = Set.new
|
||||
collect_forloop_properties(body, props)
|
||||
props
|
||||
end
|
||||
|
||||
def self.collect_forloop_properties(body, props)
|
||||
return if body.nil?
|
||||
nodelist = body.nodelist
|
||||
return if nodelist.nil?
|
||||
|
||||
nodelist.each do |node|
|
||||
case node
|
||||
when Variable
|
||||
collect_forloop_from_variable(node, props)
|
||||
when Tag
|
||||
collect_forloop_from_tag(node, props)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.collect_forloop_from_variable(var, props)
|
||||
lookup = var.name
|
||||
if lookup.is_a?(VariableLookup) && lookup.name == 'forloop'
|
||||
lookup.lookups.each do |prop|
|
||||
props << prop if prop.is_a?(String)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.collect_forloop_from_tag(tag, props)
|
||||
# Check block bodies
|
||||
[:@for_block, :@else_block, :@body, :@consequent, :@alternative].each do |ivar|
|
||||
if tag.instance_variable_defined?(ivar)
|
||||
collect_forloop_properties(tag.instance_variable_get(ivar), props)
|
||||
end
|
||||
end
|
||||
|
||||
# Check conditions
|
||||
if tag.respond_to?(:blocks)
|
||||
tag.blocks.each do |block|
|
||||
collect_forloop_from_condition(block, props) if block.respond_to?(:left)
|
||||
collect_forloop_properties(block.attachment, props) if block.respond_to?(:attachment)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.collect_forloop_from_condition(condition, props)
|
||||
[condition.left, condition.right].compact.each do |expr|
|
||||
if expr.is_a?(VariableLookup) && expr.name == 'forloop'
|
||||
expr.lookups.each do |prop|
|
||||
props << prop if prop.is_a?(String)
|
||||
end
|
||||
end
|
||||
end
|
||||
# Check child conditions
|
||||
collect_forloop_from_condition(condition.child_condition, props) if condition.respond_to?(:child_condition) && condition.child_condition
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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)"
|
||||
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 external handler for dynamic includes
|
||||
code.line "__output__ << __external__.call(:include, #{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, assigns, __context__)"
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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)"
|
||||
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 external handler for dynamic renders
|
||||
code.line "__output__ << __external__.call(:render, #{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, #{is_for_loop}, __context__)"
|
||||
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(LR.to_integer(#{offset_expr}), LR.to_integer(#{limit_expr})) || []"
|
||||
else
|
||||
code.line "#{coll_var} = #{coll_var}.drop(LR.to_integer(#{offset_expr}))"
|
||||
end
|
||||
elsif limit
|
||||
limit_expr = ExpressionCompiler.compile(limit, compiler)
|
||||
code.line "#{coll_var} = #{coll_var}.first(LR.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
|
||||
@@ -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__ << LR.output(#{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
|
||||
@@ -205,6 +205,68 @@ module Liquid
|
||||
render(context, output: output)
|
||||
end
|
||||
|
||||
# Compile the template to Ruby code for fast, secure execution.
|
||||
#
|
||||
# Returns a CompiledTemplate that can be rendered repeatedly. On Ruby 4.0+,
|
||||
# rendering happens in a secure sandbox. On earlier versions, a security
|
||||
# warning is printed to STDERR.
|
||||
#
|
||||
# == Example
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||
# compiled = template.compile_to_ruby
|
||||
#
|
||||
# # Render (fast, secure on Ruby 4.0+)
|
||||
# result = compiled.render({ "name" => "World" })
|
||||
# # => "Hello, World!"
|
||||
#
|
||||
# # Access the generated Ruby source
|
||||
# puts compiled.source
|
||||
#
|
||||
# # Check if execution is sandboxed
|
||||
# compiled.secure? # => true on Ruby 4.0+
|
||||
#
|
||||
# == Options
|
||||
#
|
||||
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
|
||||
# * <tt>:include_filters</tt> - Include helper methods for filters (default: true)
|
||||
# * <tt>:debug</tt> - Include source comments in generated code (default: false)
|
||||
#
|
||||
# == Performance
|
||||
#
|
||||
# Compiled templates are ~1.5x faster than interpreted Liquid:
|
||||
# * No Context object overhead
|
||||
# * No filter invocation overhead (direct method calls)
|
||||
# * No resource limits tracking
|
||||
# * No stack-based scoping (uses Ruby's native scoping)
|
||||
# * Direct string concatenation
|
||||
#
|
||||
# == Security
|
||||
#
|
||||
# On Ruby 4.0+, templates execute in a Ruby::Box sandbox that blocks:
|
||||
# * File/network access
|
||||
# * System calls (exec, spawn, fork)
|
||||
# * Code loading (require, eval)
|
||||
# * Dangerous metaprogramming
|
||||
#
|
||||
# On Ruby < 4.0, templates execute WITHOUT sandboxing.
|
||||
# A warning is printed to STDERR on first execution.
|
||||
#
|
||||
# == Limitations
|
||||
#
|
||||
# * {% render %} and {% include %} resolved at compile time when possible
|
||||
# * Custom tags need explicit compiler implementations
|
||||
# * Custom filters must be available at runtime
|
||||
#
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,319 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Compile Profiler - Measure allocations and performance of compiled vs interpreted Liquid
|
||||
#
|
||||
# Usage:
|
||||
# RUBY_BOX=1 ruby -W:no-experimental performance/compile_profiler.rb
|
||||
#
|
||||
# This tool measures:
|
||||
# - Allocation count (objects created during render)
|
||||
# - Time per render
|
||||
# - Comparison between interpreted and compiled
|
||||
#
|
||||
# Results are appended to ../timings.jsonl with git hash and timestamp
|
||||
#
|
||||
# REQUIRES Ruby 4.0+ with RUBY_BOX=1
|
||||
|
||||
unless ENV['RUBY_BOX'] == '1'
|
||||
$stderr.puts "\e[31mERROR: Must run with RUBY_BOX=1\e[0m"
|
||||
$stderr.puts "Usage: RUBY_BOX=1 ruby -W:no-experimental performance/compile_profiler.rb"
|
||||
exit 1
|
||||
end
|
||||
|
||||
require 'json'
|
||||
require 'time'
|
||||
require_relative '../lib/liquid'
|
||||
require_relative '../lib/liquid/compile'
|
||||
|
||||
unless Liquid::Box.secure?
|
||||
$stderr.puts "\e[31mERROR: Ruby::Box not available. Requires Ruby 4.0+\e[0m"
|
||||
exit 1
|
||||
end
|
||||
|
||||
class CompileProfiler
|
||||
COLORS = {
|
||||
reset: "\e[0m",
|
||||
bold: "\e[1m",
|
||||
red: "\e[31m",
|
||||
green: "\e[32m",
|
||||
yellow: "\e[33m",
|
||||
blue: "\e[34m",
|
||||
magenta: "\e[35m",
|
||||
cyan: "\e[36m",
|
||||
gray: "\e[90m",
|
||||
}.freeze
|
||||
|
||||
BOX_CHARS = {
|
||||
tl: "╭", tr: "╮", bl: "╰", br: "╯",
|
||||
h: "─", v: "│",
|
||||
check: "✓", cross: "✗", arrow: "→", delta: "Δ",
|
||||
}.freeze
|
||||
|
||||
TIMINGS_FILE = File.expand_path('../../timings.jsonl', __dir__)
|
||||
|
||||
def initialize
|
||||
@results = {}
|
||||
@git_hash = `git rev-parse --short HEAD 2>/dev/null`.strip
|
||||
@git_hash = "unknown" if @git_hash.empty?
|
||||
@timestamp = Time.now.utc.iso8601
|
||||
end
|
||||
|
||||
def c(color, text)
|
||||
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
||||
end
|
||||
|
||||
def box(title, width: 70)
|
||||
puts
|
||||
puts "#{BOX_CHARS[:tl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:tr]}"
|
||||
puts "#{BOX_CHARS[:v]} #{c(:bold, title)}#{' ' * (width - 4 - title.length)} #{BOX_CHARS[:v]}"
|
||||
yield if block_given?
|
||||
puts "#{BOX_CHARS[:bl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:br]}"
|
||||
end
|
||||
|
||||
# Calculate visible length (excluding ANSI codes)
|
||||
def visible_length(str)
|
||||
str.gsub(/\e\[[0-9;]*m/, '').length
|
||||
end
|
||||
|
||||
def row(label, value, width: 70)
|
||||
label_str = label.to_s
|
||||
value_str = value.to_s
|
||||
label_visible = visible_length(label_str)
|
||||
value_visible = visible_length(value_str)
|
||||
padding = width - 4 - label_visible - value_visible
|
||||
padding = 1 if padding < 1
|
||||
puts "#{BOX_CHARS[:v]} #{label_str}#{' ' * padding}#{value_str} #{BOX_CHARS[:v]}"
|
||||
end
|
||||
|
||||
def separator(width: 70)
|
||||
puts "#{BOX_CHARS[:v]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:v]}"
|
||||
end
|
||||
|
||||
def measure_allocations
|
||||
GC.start
|
||||
GC.disable
|
||||
before = GC.stat(:total_allocated_objects)
|
||||
yield
|
||||
after = GC.stat(:total_allocated_objects)
|
||||
GC.enable
|
||||
after - before
|
||||
end
|
||||
|
||||
def measure_time(iterations: 100)
|
||||
GC.start
|
||||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
iterations.times { yield }
|
||||
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
||||
(elapsed / iterations * 1_000_000).round(2) # microseconds
|
||||
end
|
||||
|
||||
def measure_objects
|
||||
before = ObjectSpace.count_objects.dup
|
||||
yield
|
||||
after = ObjectSpace.count_objects
|
||||
|
||||
diff = {}
|
||||
after.each do |k, v|
|
||||
d = v - (before[k] || 0)
|
||||
diff[k] = d if d > 0
|
||||
end
|
||||
diff
|
||||
end
|
||||
|
||||
def profile_template(name, source, assigns, iterations: 100)
|
||||
puts
|
||||
puts c(:cyan, "#{BOX_CHARS[:arrow]} Profiling: #{c(:bold, name)}")
|
||||
puts c(:gray, " Template: #{source[0..60]}#{'...' if source.length > 60}")
|
||||
|
||||
template = Liquid::Template.parse(source)
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
# Warmup
|
||||
10.times { template.render(assigns.dup) }
|
||||
10.times { compiled.render(assigns.dup) }
|
||||
|
||||
# Measure interpreted
|
||||
interp_allocs = measure_allocations { template.render(assigns.dup) }
|
||||
interp_time = measure_time(iterations: iterations) { template.render(assigns.dup) }
|
||||
interp_objects = measure_objects { template.render(assigns.dup) }
|
||||
|
||||
# Measure compiled
|
||||
comp_allocs = measure_allocations { compiled.render(assigns.dup) }
|
||||
comp_time = measure_time(iterations: iterations) { compiled.render(assigns.dup) }
|
||||
comp_objects = measure_objects { compiled.render(assigns.dup) }
|
||||
|
||||
# Calculate deltas
|
||||
alloc_delta = ((comp_allocs.to_f / interp_allocs - 1) * 100).round(1)
|
||||
time_delta = ((comp_time / interp_time - 1) * 100).round(1)
|
||||
|
||||
alloc_color = alloc_delta < 0 ? :green : :red
|
||||
time_color = time_delta < 0 ? :green : :red
|
||||
|
||||
box(name) do
|
||||
# Header row
|
||||
header = "#{' ' * 20}#{c(:gray, 'Interpreted')} #{c(:cyan, 'Compiled')} #{c(:yellow, 'Delta')}"
|
||||
row(header, "")
|
||||
separator
|
||||
# Data rows with fixed-width columns
|
||||
alloc_delta_str = "#{alloc_delta > 0 ? '+' : ''}#{alloc_delta}%"
|
||||
time_delta_str = "#{time_delta > 0 ? '+' : ''}#{time_delta}%"
|
||||
row("Allocations", "#{interp_allocs.to_s.rjust(11)} #{comp_allocs.to_s.rjust(8)} #{c(alloc_color, alloc_delta_str.rjust(7))}")
|
||||
row("Time (μs)", "#{interp_time.to_s.rjust(11)} #{comp_time.to_s.rjust(8)} #{c(time_color, time_delta_str.rjust(7))}")
|
||||
separator
|
||||
row("Objects (compiled):", "")
|
||||
comp_objects.sort_by { |_, v| -v }.first(3).each do |type, count|
|
||||
row(" #{type}", count.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@results[name] = {
|
||||
interp_allocs: interp_allocs,
|
||||
comp_allocs: comp_allocs,
|
||||
interp_time: interp_time,
|
||||
comp_time: comp_time,
|
||||
alloc_delta: alloc_delta,
|
||||
time_delta: time_delta,
|
||||
}
|
||||
end
|
||||
|
||||
def print_summary
|
||||
return if @results.empty?
|
||||
|
||||
width = 70
|
||||
total_interp_allocs = @results.values.sum { |r| r[:interp_allocs] }
|
||||
total_comp_allocs = @results.values.sum { |r| r[:comp_allocs] }
|
||||
total_interp_time = @results.values.sum { |r| r[:interp_time] }
|
||||
total_comp_time = @results.values.sum { |r| r[:comp_time] }
|
||||
|
||||
alloc_improvement = ((1 - total_comp_allocs.to_f / total_interp_allocs) * 100).round(1)
|
||||
time_improvement = ((1 - total_comp_time / total_interp_time) * 100).round(1)
|
||||
|
||||
alloc_icon = alloc_improvement > 0 ? c(:green, BOX_CHARS[:check]) : c(:red, BOX_CHARS[:cross])
|
||||
time_icon = time_improvement > 0 ? c(:green, BOX_CHARS[:check]) : c(:red, BOX_CHARS[:cross])
|
||||
|
||||
alloc_text = "#{alloc_icon} Allocations: #{c(:bold, "#{alloc_improvement}%")} #{alloc_improvement > 0 ? 'fewer' : 'more'} (#{total_comp_allocs} vs #{total_interp_allocs})"
|
||||
time_text = "#{time_icon} Time: #{c(:bold, "#{time_improvement}%")} #{time_improvement > 0 ? 'faster' : 'slower'} (#{total_comp_time.round(0)}μs vs #{total_interp_time.round(0)}μs)"
|
||||
|
||||
puts
|
||||
puts "#{BOX_CHARS[:tl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:tr]}"
|
||||
title = "SUMMARY"
|
||||
title_pad = (width - 4 - title.length) / 2
|
||||
puts "#{BOX_CHARS[:v]} #{' ' * title_pad}#{c(:bold, title)}#{' ' * (width - 4 - title_pad - title.length)} #{BOX_CHARS[:v]}"
|
||||
puts "#{BOX_CHARS[:v]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:v]}"
|
||||
alloc_pad = width - 4 - visible_length(alloc_text)
|
||||
puts "#{BOX_CHARS[:v]} #{alloc_text}#{' ' * alloc_pad} #{BOX_CHARS[:v]}"
|
||||
time_pad = width - 4 - visible_length(time_text)
|
||||
puts "#{BOX_CHARS[:v]} #{time_text}#{' ' * time_pad} #{BOX_CHARS[:v]}"
|
||||
puts "#{BOX_CHARS[:bl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:br]}"
|
||||
|
||||
# Write to timings.jsonl
|
||||
write_timings(total_interp_allocs, total_comp_allocs, total_interp_time, total_comp_time,
|
||||
alloc_improvement, time_improvement)
|
||||
end
|
||||
|
||||
def write_timings(total_interp_allocs, total_comp_allocs, total_interp_time, total_comp_time,
|
||||
alloc_improvement, time_improvement)
|
||||
entry = {
|
||||
timestamp: @timestamp,
|
||||
git_hash: @git_hash,
|
||||
ruby_version: RUBY_VERSION,
|
||||
summary: {
|
||||
alloc_improvement_pct: alloc_improvement,
|
||||
time_improvement_pct: time_improvement,
|
||||
total_interp_allocs: total_interp_allocs,
|
||||
total_comp_allocs: total_comp_allocs,
|
||||
total_interp_time_us: total_interp_time.round(2),
|
||||
total_comp_time_us: total_comp_time.round(2),
|
||||
},
|
||||
benchmarks: @results.transform_values { |r|
|
||||
{
|
||||
interp_allocs: r[:interp_allocs],
|
||||
comp_allocs: r[:comp_allocs],
|
||||
interp_time_us: r[:interp_time],
|
||||
comp_time_us: r[:comp_time],
|
||||
alloc_delta_pct: r[:alloc_delta],
|
||||
time_delta_pct: r[:time_delta],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File.open(TIMINGS_FILE, 'a') do |f|
|
||||
f.puts JSON.generate(entry)
|
||||
end
|
||||
|
||||
puts
|
||||
puts c(:gray, "Results appended to #{TIMINGS_FILE}")
|
||||
end
|
||||
|
||||
def run_all
|
||||
puts c(:bold, "\n🔬 Liquid Compile Profiler")
|
||||
puts c(:gray, " Measuring allocations and performance...\n")
|
||||
|
||||
profile_template(
|
||||
"Simple variable",
|
||||
"Hello, {{ name }}!",
|
||||
{ "name" => "World" }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Variable with filter",
|
||||
"{{ name | upcase | prepend: 'Hello, ' | append: '!' }}",
|
||||
{ "name" => "world" }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Simple loop",
|
||||
"{% for item in items %}{{ item }} {% endfor %}",
|
||||
{ "items" => %w[a b c d e] }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Loop with forloop",
|
||||
"{% for item in items %}{{ forloop.index }}: {{ item }} {% endfor %}",
|
||||
{ "items" => %w[a b c d e] }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Nested loop",
|
||||
"{% for i in outer %}{% for j in inner %}{{ i }}.{{ j }} {% endfor %}{% endfor %}",
|
||||
{ "outer" => [1, 2, 3], "inner" => %w[a b c] }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Conditionals",
|
||||
"{% if show %}{% if big %}BIG{% else %}small{% endif %}{% else %}hidden{% endif %}",
|
||||
{ "show" => true, "big" => false }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Property access",
|
||||
"{{ user.profile.name }} - {{ user.profile.email }}",
|
||||
{ "user" => { "profile" => { "name" => "Alice", "email" => "[email protected]" } } }
|
||||
)
|
||||
|
||||
profile_template(
|
||||
"Complex template",
|
||||
<<~LIQUID,
|
||||
{% for product in products %}
|
||||
{{ forloop.index }}. {{ product.name | upcase }}
|
||||
{% if product.on_sale %}SALE: ${{ product.price | times: 0.8 }}{% else %}${{ product.price }}{% endif %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
{
|
||||
"products" => [
|
||||
{ "name" => "Widget", "price" => 100, "on_sale" => true },
|
||||
{ "name" => "Gadget", "price" => 200, "on_sale" => false },
|
||||
{ "name" => "Gizmo", "price" => 150, "on_sale" => true },
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
print_summary
|
||||
end
|
||||
end
|
||||
|
||||
# Run the profiler
|
||||
if __FILE__ == $0
|
||||
profiler = CompileProfiler.new
|
||||
profiler.run_all
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,211 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
require 'yaml'
|
||||
|
||||
# Load Shopify-style tags and filters for performance templates
|
||||
require_relative '../../performance/shopify/comment_form'
|
||||
require_relative '../../performance/shopify/paginate'
|
||||
require_relative '../../performance/shopify/json_filter'
|
||||
require_relative '../../performance/shopify/money_filter'
|
||||
require_relative '../../performance/shopify/shop_filter'
|
||||
require_relative '../../performance/shopify/tag_filter'
|
||||
require_relative '../../performance/shopify/weight_filter'
|
||||
|
||||
# Acceptance tests for compiled templates
|
||||
#
|
||||
# These tests run every performance benchmark template through both
|
||||
# the interpreted Liquid renderer and the compiled Ruby renderer,
|
||||
# verifying that outputs match exactly.
|
||||
#
|
||||
# Run with: RUBY_BOX=1 ruby -W:no-experimental -Ilib:test test/unit/compile_acceptance_test.rb
|
||||
class CompileAcceptanceTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
PERFORMANCE_DIR = File.expand_path('../../performance', __dir__)
|
||||
TESTS_DIR = File.join(PERFORMANCE_DIR, 'tests')
|
||||
DATABASE_FILE = File.join(PERFORMANCE_DIR, 'shopify/vision.database.yml')
|
||||
|
||||
class << self
|
||||
def database
|
||||
@database ||= load_database
|
||||
end
|
||||
|
||||
def load_database
|
||||
db = if YAML.respond_to?(:unsafe_load_file)
|
||||
YAML.unsafe_load_file(DATABASE_FILE)
|
||||
else
|
||||
YAML.load_file(DATABASE_FILE)
|
||||
end
|
||||
|
||||
# From vision source - link products to collections
|
||||
db['products'].each do |product|
|
||||
collections = db['collections'].find_all do |collection|
|
||||
collection['products'].any? { |p| p['id'].to_i == product['id'].to_i }
|
||||
end
|
||||
product['collections'] = collections
|
||||
end
|
||||
|
||||
# Key tables by handles
|
||||
db = db.each_with_object({}) do |(key, values), assigns|
|
||||
assigns[key] = values.each_with_object({}) do |v, h|
|
||||
h[v['handle']] = v
|
||||
end
|
||||
end
|
||||
|
||||
# Standard direct accessors
|
||||
db['collection'] = db['collections'].values.first
|
||||
db['product'] = db['products'].values.first
|
||||
db['blog'] = db['blogs'].values.first
|
||||
db['article'] = db['blog']['articles'].first
|
||||
db['cart'] = {
|
||||
'total_price' => db['line_items'].values.inject(0) { |sum, item| sum + item['line_price'] * item['quantity'] },
|
||||
'item_count' => db['line_items'].values.inject(0) { |sum, item| sum + item['quantity'] },
|
||||
'items' => db['line_items'].values,
|
||||
}
|
||||
|
||||
db
|
||||
end
|
||||
|
||||
def register_shopify_extensions!
|
||||
return if @extensions_registered
|
||||
|
||||
env = Liquid::Environment.default
|
||||
env.register_tag('paginate', Paginate)
|
||||
env.register_tag('form', CommentForm)
|
||||
env.register_filter(JsonFilter)
|
||||
env.register_filter(MoneyFilter)
|
||||
env.register_filter(WeightFilter)
|
||||
env.register_filter(ShopFilter)
|
||||
env.register_filter(TagFilter)
|
||||
|
||||
@extensions_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
# File system for {% render %} and {% include %} tags
|
||||
class TestFileSystem
|
||||
def initialize(path)
|
||||
@path = path
|
||||
end
|
||||
|
||||
def read_template_file(template_path)
|
||||
File.read(File.join(@path, "#{template_path}.liquid"))
|
||||
end
|
||||
end
|
||||
|
||||
def setup
|
||||
self.class.register_shopify_extensions!
|
||||
@database = self.class.database
|
||||
end
|
||||
|
||||
# Find all test templates and generate a test method for each
|
||||
Dir.glob(File.join(TESTS_DIR, '**/*.liquid')).each do |template_path|
|
||||
# Skip theme.liquid files - they're layouts, not standalone templates
|
||||
next if File.basename(template_path) == 'theme.liquid'
|
||||
|
||||
# Extract theme name and template name for test method name
|
||||
relative_path = template_path.sub("#{TESTS_DIR}/", '')
|
||||
theme_name = File.dirname(relative_path)
|
||||
template_name = File.basename(relative_path, '.liquid')
|
||||
|
||||
test_method_name = "test_#{theme_name}_#{template_name}".gsub(/[^a-zA-Z0-9_]/, '_')
|
||||
|
||||
define_method(test_method_name) do
|
||||
run_acceptance_test(template_path, theme_name, template_name)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_acceptance_test(template_path, theme_name, template_name)
|
||||
# Read the template
|
||||
template_source = File.read(template_path)
|
||||
|
||||
# Check for a theme layout
|
||||
theme_path = File.join(File.dirname(template_path), 'theme.liquid')
|
||||
layout_source = File.exist?(theme_path) ? File.read(theme_path) : nil
|
||||
|
||||
# Set up assigns
|
||||
assigns = @database.dup
|
||||
assigns['page_title'] = 'Test Page'
|
||||
assigns['template'] = template_name
|
||||
|
||||
# Set up file system for partials
|
||||
file_system = TestFileSystem.new(File.dirname(template_path))
|
||||
|
||||
# Render with interpreted Liquid
|
||||
interpreted_output = render_interpreted(template_source, layout_source, assigns, file_system)
|
||||
|
||||
# Render with compiled Ruby
|
||||
compiled_output = render_compiled(template_source, layout_source, assigns, file_system)
|
||||
|
||||
# Compare outputs
|
||||
assert_equal(
|
||||
interpreted_output,
|
||||
compiled_output,
|
||||
"Output mismatch for #{theme_name}/#{template_name}.liquid\n" \
|
||||
"Interpreted length: #{interpreted_output.length}\n" \
|
||||
"Compiled length: #{compiled_output.length}\n" \
|
||||
"First difference at: #{find_first_diff(interpreted_output, compiled_output)}"
|
||||
)
|
||||
end
|
||||
|
||||
def render_interpreted(template_source, layout_source, assigns, file_system)
|
||||
template = Template.parse(template_source)
|
||||
template.registers[:file_system] = file_system
|
||||
|
||||
content = template.render!(assigns.dup)
|
||||
|
||||
if layout_source
|
||||
layout = Template.parse(layout_source)
|
||||
layout.registers[:file_system] = file_system
|
||||
layout_assigns = assigns.dup
|
||||
layout_assigns['content_for_layout'] = content
|
||||
layout.render!(layout_assigns)
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def render_compiled(template_source, layout_source, assigns, file_system)
|
||||
template = Template.parse(template_source)
|
||||
compiled = template.compile_to_ruby
|
||||
|
||||
# Set up filter handler with Shopify filters
|
||||
filter_handler = Class.new do
|
||||
include JsonFilter
|
||||
include MoneyFilter
|
||||
include WeightFilter
|
||||
include ShopFilter
|
||||
include TagFilter
|
||||
end.new
|
||||
|
||||
compiled.filter_handler = filter_handler
|
||||
|
||||
content = compiled.call(assigns.dup, registers: { file_system: file_system })
|
||||
|
||||
if layout_source
|
||||
layout = Template.parse(layout_source)
|
||||
layout_compiled = layout.compile_to_ruby
|
||||
layout_compiled.filter_handler = filter_handler
|
||||
layout_assigns = assigns.dup
|
||||
layout_assigns['content_for_layout'] = content
|
||||
layout_compiled.call(layout_assigns, registers: { file_system: file_system })
|
||||
else
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def find_first_diff(str1, str2)
|
||||
min_len = [str1.length, str2.length].min
|
||||
diff_pos = (0...min_len).find { |i| str1[i] != str2[i] } || min_len
|
||||
|
||||
context_start = [0, diff_pos - 20].max
|
||||
context_end = [str1.length, str2.length, diff_pos + 30].min
|
||||
|
||||
"position #{diff_pos}:\n" \
|
||||
" Interpreted: #{str1[context_start...context_end].inspect}\n" \
|
||||
" Compiled: #{str2[context_start...context_end].inspect}"
|
||||
end
|
||||
end
|
||||
@@ -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 "<p>hello</p>", 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
|
||||
Reference in New Issue
Block a user