mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-15 00:40:40 -07:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9416d7a9ef | ||
|
|
542deb4a21 | ||
|
|
c5a44be104 | ||
|
|
652b9c0897 | ||
|
|
6c0d599c89 | ||
|
|
560d2ce9d4 | ||
|
|
154343e64f | ||
|
|
6e680a35b3 | ||
|
|
9367b8b32e | ||
|
|
5cdbce7d6e | ||
|
|
520e86b8c8 | ||
|
|
54e3d2328a | ||
|
|
7be2922f91 | ||
|
|
e0f856fd11 |
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
|
||||
name: Test Ruby ${{ matrix.entry.ruby }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: ${{ matrix.entry.ruby }}
|
||||
bundler-cache: true
|
||||
@@ -45,8 +45,8 @@ jobs:
|
||||
memory_profile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
- run: bundle exec rake memory_profile:run
|
||||
|
||||
+1
-14
@@ -1,19 +1,6 @@
|
||||
# Liquid Change Log
|
||||
|
||||
## 5.10.0
|
||||
* Introduce support for Inline Snippets [Julia Boutin]
|
||||
```
|
||||
{%- snippet snowdevil -%}
|
||||
Snowdevil
|
||||
{%- endsnippet -%}
|
||||
{% render snowdevil %}
|
||||
```
|
||||
|
||||
## 5.9.0
|
||||
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]
|
||||
|
||||
## 5.8.7
|
||||
* Expose body content in the `Doc` tag [James Meng]
|
||||
## 5.8.1 (unreleased)
|
||||
|
||||
## 5.8.1
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -99,14 +99,14 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem
|
||||
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
|
||||
it very hard to debug and can lead to unexpected behaviour.
|
||||
|
||||
Liquid also comes with different parsers that can be used when editing templates to give better error messages
|
||||
Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
|
||||
when templates are invalid. You can enable this new parser like this:
|
||||
|
||||
```ruby
|
||||
Liquid::Environment.default.error_mode = :rigid # Raises a SyntaxError when invalid syntax is used in all tags
|
||||
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
|
||||
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
|
||||
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
|
||||
Liquid::Environment.default.error_mode = :strict
|
||||
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
|
||||
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
|
||||
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
|
||||
```
|
||||
|
||||
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
|
||||
|
||||
@@ -33,7 +33,7 @@ task :rubocop do
|
||||
end
|
||||
end
|
||||
|
||||
desc('runs test suite with lax, strict, and rigid parsers')
|
||||
desc('runs test suite with both strict and lax parsers')
|
||||
task :test do
|
||||
ENV['LIQUID_PARSER_MODE'] = 'lax'
|
||||
Rake::Task['base_test'].invoke
|
||||
@@ -42,10 +42,6 @@ task :test do
|
||||
Rake::Task['base_test'].reenable
|
||||
Rake::Task['base_test'].invoke
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'rigid'
|
||||
Rake::Task['base_test'].reenable
|
||||
Rake::Task['base_test'].invoke
|
||||
|
||||
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
|
||||
ENV['LIQUID_PARSER_MODE'] = 'lax'
|
||||
Rake::Task['integration_test'].reenable
|
||||
@@ -54,10 +50,6 @@ task :test do
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'rigid'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
end
|
||||
end
|
||||
|
||||
@@ -88,13 +80,8 @@ namespace :benchmark do
|
||||
ruby "./performance/benchmark.rb strict"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with rigid parsing"
|
||||
task :rigid do
|
||||
ruby "./performance/benchmark.rb rigid"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with lax, strict, and rigid parsing"
|
||||
task run: [:lax, :strict, :rigid]
|
||||
desc "Run the liquid benchmark with both lax and strict parsing"
|
||||
task run: [:lax, :strict]
|
||||
|
||||
desc "Run unit benchmarks"
|
||||
namespace :unit do
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/setup'
|
||||
require 'liquid'
|
||||
|
||||
class VirtualFileSystem
|
||||
def initialize
|
||||
snippet_1 = <<~LIQUID
|
||||
<h1>
|
||||
{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
|
||||
</h1>
|
||||
LIQUID
|
||||
snippet_2 = <<~LIQUID
|
||||
{%- for i in (1..5) -%}
|
||||
> {{ i }}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
@templates = {
|
||||
'snippet-1' => snippet_1,
|
||||
'snippet-2' => snippet_2,
|
||||
}
|
||||
end
|
||||
|
||||
def read_template_file(key)
|
||||
@templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'")
|
||||
end
|
||||
end
|
||||
|
||||
def source
|
||||
File.read(ARGV[0])
|
||||
rescue StandardError
|
||||
'Usage: bin/render example/server/templates/index.liquid'
|
||||
end
|
||||
|
||||
def assigns
|
||||
{
|
||||
'date' => Time.now,
|
||||
}
|
||||
end
|
||||
|
||||
puts Liquid::Template
|
||||
.parse(source, error_mode: :rigid)
|
||||
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
|
||||
.render(assigns)
|
||||
+1
-1
@@ -67,7 +67,6 @@ require 'liquid/i18n'
|
||||
require 'liquid/drop'
|
||||
require 'liquid/tablerowloop_drop'
|
||||
require 'liquid/forloop_drop'
|
||||
require 'liquid/snippet_drop'
|
||||
require 'liquid/extensions'
|
||||
require 'liquid/errors'
|
||||
require 'liquid/interrupts'
|
||||
@@ -90,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
|
||||
@@ -48,8 +48,8 @@ module Liquid
|
||||
@@operators
|
||||
end
|
||||
|
||||
def self.parse_expression(parse_context, markup, safe: false)
|
||||
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
|
||||
def self.parse_expression(parse_context, markup)
|
||||
@@method_literals[markup] || parse_context.parse_expression(markup)
|
||||
end
|
||||
|
||||
attr_reader :attachment, :child_condition
|
||||
|
||||
@@ -34,7 +34,7 @@ module Liquid
|
||||
# @param file_system The default file system that is used
|
||||
# to load templates from.
|
||||
# @param error_mode [Symbol] The default error mode for all templates
|
||||
# (either :rigid, :strict, :warn, or :lax).
|
||||
# (either :strict, :warn, or :lax).
|
||||
# @param exception_renderer [Proc] The exception renderer that is used to
|
||||
# render exceptions.
|
||||
# @yieldparam environment [Environment] The environment instance that is being built.
|
||||
|
||||
@@ -28,10 +28,6 @@ module Liquid
|
||||
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
|
||||
|
||||
class << self
|
||||
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
|
||||
parse(parser.expression, ss, cache)
|
||||
end
|
||||
|
||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||
return unless markup
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}"
|
||||
assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
|
||||
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
|
||||
snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [var]"
|
||||
case: "Syntax Error in 'case' - Valid syntax: case [condition]"
|
||||
case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}"
|
||||
case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) "
|
||||
@@ -20,9 +19,7 @@
|
||||
invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}"
|
||||
invalid_template_encoding: "Invalid template encoding"
|
||||
render: "Syntax error in tag 'render' - Template name must be a quoted string"
|
||||
render_invalid_template_name: "Syntax error in tag 'render' - Expected a string or identifier, found %{found}"
|
||||
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
|
||||
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
|
||||
tag_never_closed: "'%{block_name}' tag was never closed"
|
||||
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
|
||||
unexpected_else: "%{block_name} tag does not expect 'else' tag"
|
||||
@@ -31,6 +28,5 @@
|
||||
variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}"
|
||||
argument:
|
||||
include: "Argument error in tag 'include' - Illegal template name"
|
||||
render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed"
|
||||
disabled:
|
||||
tag: "usage is not allowed in this context"
|
||||
|
||||
@@ -50,22 +50,7 @@ module Liquid
|
||||
)
|
||||
end
|
||||
|
||||
def safe_parse_expression(parser)
|
||||
Expression.safe_parse(parser, @string_scanner, @expression_cache)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
if !safe && @error_mode == :rigid
|
||||
# parse_expression is a widely used API. To maintain backward
|
||||
# compatibility while raising awareness about rigid parser standards,
|
||||
# the safe flag supports API users make a deliberate decision.
|
||||
#
|
||||
# In rigid mode, markup MUST come from a string returned by the parser
|
||||
# (e.g., parser.expression). We're not calling the parser here to
|
||||
# prevent redundant parser overhead.
|
||||
raise Liquid::InternalError, "unsafe parse_expression cannot be used in rigid mode"
|
||||
end
|
||||
|
||||
def parse_expression(markup)
|
||||
Expression.parse(markup, @string_scanner, @expression_cache)
|
||||
end
|
||||
|
||||
|
||||
@@ -2,22 +2,10 @@
|
||||
|
||||
module Liquid
|
||||
module ParserSwitching
|
||||
# Do not use this.
|
||||
#
|
||||
# It's basically doing the same thing the {#parse_with_selected_parser},
|
||||
# except this will try the strict parser regardless of the error mode,
|
||||
# and fall back to the lax parser if the error mode is lax or warn,
|
||||
# except when in rigid mode where it uses the rigid parser.
|
||||
#
|
||||
# @deprecated Use {#parse_with_selected_parser} instead.
|
||||
def strict_parse_with_error_mode_fallback(markup)
|
||||
return rigid_parse_with_error_context(markup) if rigid_mode?
|
||||
|
||||
strict_parse_with_error_context(markup)
|
||||
rescue SyntaxError => e
|
||||
case parse_context.error_mode
|
||||
when :rigid
|
||||
raise
|
||||
when :strict
|
||||
raise
|
||||
when :warn
|
||||
@@ -28,12 +16,11 @@ module Liquid
|
||||
|
||||
def parse_with_selected_parser(markup)
|
||||
case parse_context.error_mode
|
||||
when :rigid then rigid_parse_with_error_context(markup)
|
||||
when :strict then strict_parse_with_error_context(markup)
|
||||
when :lax then lax_parse(markup)
|
||||
when :warn
|
||||
begin
|
||||
rigid_parse_with_error_context(markup)
|
||||
strict_parse_with_error_context(markup)
|
||||
rescue SyntaxError => e
|
||||
parse_context.warnings << e
|
||||
lax_parse(markup)
|
||||
@@ -41,20 +28,8 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
def rigid_mode?
|
||||
parse_context.error_mode == :rigid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rigid_parse_with_error_context(markup)
|
||||
rigid_parse(markup)
|
||||
rescue SyntaxError => e
|
||||
e.line_number = line_number
|
||||
e.markup_context = markup_context(markup)
|
||||
raise e
|
||||
end
|
||||
|
||||
def strict_parse_with_error_context(markup)
|
||||
strict_parse(markup)
|
||||
rescue SyntaxError => e
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
class SnippetDrop < Drop
|
||||
attr_reader :body, :name, :filename
|
||||
|
||||
def initialize(body, name, filename)
|
||||
super()
|
||||
@body = body
|
||||
@name = name
|
||||
@filename = filename
|
||||
end
|
||||
|
||||
def to_partial
|
||||
@body
|
||||
end
|
||||
|
||||
def to_s
|
||||
'SnippetDrop'
|
||||
end
|
||||
end
|
||||
end
|
||||
+2
-6
@@ -68,12 +68,8 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def safe_parse_expression(parser)
|
||||
parse_context.safe_parse_expression(parser)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
parse_context.parse_expression(markup, safe: safe)
|
||||
def parse_expression(markup)
|
||||
parse_context.parse_expression(markup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,6 @@ require_relative "tags/raw"
|
||||
require_relative "tags/render"
|
||||
require_relative "tags/cycle"
|
||||
require_relative "tags/doc"
|
||||
require_relative "tags/snippet"
|
||||
|
||||
module Liquid
|
||||
module Tags
|
||||
@@ -45,7 +44,6 @@ module Liquid
|
||||
'echo' => Echo,
|
||||
'tablerow' => TableRow,
|
||||
'doc' => Doc,
|
||||
'snippet' => Snippet,
|
||||
}.freeze
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,10 +9,6 @@ module Liquid
|
||||
# Creates a new variable.
|
||||
# @liquid_description
|
||||
# You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_syntax
|
||||
# {% assign variable_name = value %}
|
||||
# @liquid_syntax_keyword variable_name The name of the variable being created.
|
||||
|
||||
@@ -9,10 +9,6 @@ module Liquid
|
||||
# Creates a new variable with a string value.
|
||||
# @liquid_description
|
||||
# You can create complex strings with Liquid logic and variables.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_syntax
|
||||
# {% capture variable %}
|
||||
# value
|
||||
|
||||
+6
-42
@@ -31,7 +31,12 @@ module Liquid
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
@blocks = []
|
||||
parse_with_selected_parser(markup)
|
||||
|
||||
if markup =~ Syntax
|
||||
@left = parse_expression(Regexp.last_match(1))
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case")
|
||||
end
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
@@ -86,50 +91,9 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def rigid_parse(markup)
|
||||
parser = @parse_context.new_parser(markup)
|
||||
@left = safe_parse_expression(parser)
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@left = parse_expression(Regexp.last_match(1))
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case")
|
||||
end
|
||||
end
|
||||
|
||||
def record_when_condition(markup)
|
||||
body = new_body
|
||||
|
||||
if rigid_mode?
|
||||
parse_rigid_when(markup, body)
|
||||
else
|
||||
parse_lax_when(markup, body)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_rigid_when(markup, body)
|
||||
parser = @parse_context.new_parser(markup)
|
||||
|
||||
loop do
|
||||
expr = safe_parse_expression(parser)
|
||||
block = Condition.new(@left, '==', expr)
|
||||
block.attach(body)
|
||||
@blocks << block
|
||||
|
||||
break unless parser.id?('or') || parser.consume?(:comma)
|
||||
end
|
||||
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def parse_lax_when(markup, body)
|
||||
while markup
|
||||
unless markup =~ WhenSyntax
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
|
||||
|
||||
+16
-69
@@ -17,13 +17,23 @@ module Liquid
|
||||
class Cycle < Tag
|
||||
SimpleSyntax = /\A#{QuotedFragment}+/o
|
||||
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
|
||||
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
|
||||
|
||||
attr_reader :variables
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
case markup
|
||||
when NamedSyntax
|
||||
@variables = variables_from_string(Regexp.last_match(2))
|
||||
@name = parse_expression(Regexp.last_match(1))
|
||||
@is_named = true
|
||||
when SimpleSyntax
|
||||
@variables = variables_from_string(markup)
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(/\w+:0x\h{8}/)
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
|
||||
end
|
||||
end
|
||||
|
||||
def named?
|
||||
@@ -55,82 +65,19 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
# cycle [name:] expression(, expression)*
|
||||
def rigid_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@variables = []
|
||||
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
|
||||
|
||||
first_expression = safe_parse_expression(p)
|
||||
if p.look(:colon)
|
||||
# cycle name: expr1, expr2, ...
|
||||
@name = first_expression
|
||||
@is_named = true
|
||||
p.consume(:colon)
|
||||
# After the colon, parse the first variable (required for named cycles)
|
||||
@variables << maybe_dup_lookup(safe_parse_expression(p))
|
||||
else
|
||||
# cycle expr1, expr2, ...
|
||||
@variables << maybe_dup_lookup(first_expression)
|
||||
end
|
||||
|
||||
# Parse remaining comma-separated expressions
|
||||
while p.consume?(:comma)
|
||||
break if p.look(:end_of_string)
|
||||
|
||||
@variables << maybe_dup_lookup(safe_parse_expression(p))
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
|
||||
unless @is_named
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
|
||||
end
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
case markup
|
||||
when NamedSyntax
|
||||
@variables = variables_from_string(Regexp.last_match(2))
|
||||
@name = parse_expression(Regexp.last_match(1))
|
||||
@is_named = true
|
||||
when SimpleSyntax
|
||||
@variables = variables_from_string(markup)
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
|
||||
end
|
||||
end
|
||||
|
||||
def variables_from_string(markup)
|
||||
markup.split(',').collect do |var|
|
||||
var =~ /\s*(#{QuotedFragment})\s*/o
|
||||
next unless Regexp.last_match(1)
|
||||
|
||||
# Expression Parser returns cached objects, and we need to dup them to
|
||||
# start the cycle over for each new cycle call.
|
||||
# Liquid-C does not have a cache, so we don't need to dup the object.
|
||||
var = parse_expression(Regexp.last_match(1))
|
||||
maybe_dup_lookup(var)
|
||||
var.is_a?(VariableLookup) ? var.dup : var
|
||||
end.compact
|
||||
end
|
||||
|
||||
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
|
||||
# we make it so that the @variables.to_s produces different strings for cycles
|
||||
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
|
||||
# This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value.
|
||||
# This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter.
|
||||
# I was really scratching my head about this one, but migrating away from this would be more headache
|
||||
# than it's worth. So we're keeping this quirk for now.
|
||||
def maybe_dup_lookup(var)
|
||||
var.is_a?(VariableLookup) ? var.dup : var
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
Array(@node.variables)
|
||||
|
||||
@@ -7,10 +7,6 @@ module Liquid
|
||||
# @liquid_name decrement
|
||||
# @liquid_summary
|
||||
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_description
|
||||
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
|
||||
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
|
||||
|
||||
@@ -36,8 +36,6 @@ module Liquid
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
@body = +""
|
||||
|
||||
while (token = tokens.shift)
|
||||
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
|
||||
|
||||
@@ -45,10 +43,8 @@ module Liquid
|
||||
|
||||
if tag_name == block_delimiter
|
||||
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
|
||||
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
|
||||
return
|
||||
end
|
||||
@body << token unless token.empty?
|
||||
end
|
||||
|
||||
raise_tag_never_closed(block_name)
|
||||
@@ -59,11 +55,11 @@ module Liquid
|
||||
end
|
||||
|
||||
def blank?
|
||||
@body.empty?
|
||||
true
|
||||
end
|
||||
|
||||
def nodelist
|
||||
[@body]
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
+7
-11
@@ -20,8 +20,8 @@ module Liquid
|
||||
# @liquid_syntax_keyword variable The current item in the array.
|
||||
# @liquid_syntax_keyword array The array to iterate over.
|
||||
# @liquid_syntax_keyword expression The expression to render for each iteration.
|
||||
# @liquid_optional_param limit: [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param limit [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
|
||||
# @liquid_optional_param reversed [untyped] Iterate in reverse order.
|
||||
class For < Block
|
||||
@@ -93,7 +93,7 @@ module Liquid
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
|
||||
|
||||
collection_name = p.expression
|
||||
@collection_name = parse_expression(collection_name, safe: true)
|
||||
@collection_name = parse_expression(collection_name)
|
||||
|
||||
@name = "#{@variable_name}-#{collection_name}"
|
||||
@reversed = p.id?('reversed')
|
||||
@@ -104,17 +104,13 @@ module Liquid
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
|
||||
end
|
||||
p.consume(:colon)
|
||||
set_attribute(attribute, p.expression, safe: true)
|
||||
set_attribute(attribute, p.expression)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rigid_parse(markup)
|
||||
strict_parse(markup)
|
||||
end
|
||||
|
||||
def collection_segment(context)
|
||||
offsets = context.registers[:for] ||= {}
|
||||
|
||||
@@ -178,16 +174,16 @@ module Liquid
|
||||
output
|
||||
end
|
||||
|
||||
def set_attribute(key, expr, safe: false)
|
||||
def set_attribute(key, expr)
|
||||
case key
|
||||
when 'offset'
|
||||
@from = if expr == 'continue'
|
||||
:continue
|
||||
else
|
||||
parse_expression(expr, safe: safe)
|
||||
parse_expression(expr)
|
||||
end
|
||||
when 'limit'
|
||||
@limit = parse_expression(expr, safe: safe)
|
||||
@limit = parse_expression(expr)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -66,10 +66,6 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def rigid_parse(markup)
|
||||
strict_parse(markup)
|
||||
end
|
||||
|
||||
def push_block(tag, markup)
|
||||
block = if tag == 'else'
|
||||
ElseCondition.new
|
||||
@@ -81,8 +77,8 @@ module Liquid
|
||||
block.attach(new_body)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
Condition.parse_expression(parse_context, markup, safe: safe)
|
||||
def parse_expression(markup)
|
||||
Condition.parse_expression(parse_context, markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
@@ -124,9 +120,9 @@ module Liquid
|
||||
end
|
||||
|
||||
def parse_comparison(p)
|
||||
a = parse_expression(p.expression, safe: true)
|
||||
a = parse_expression(p.expression)
|
||||
if (op = p.consume?(:comparison))
|
||||
b = parse_expression(p.expression, safe: true)
|
||||
b = parse_expression(p.expression)
|
||||
Condition.new(a, op, b)
|
||||
else
|
||||
Condition.new(a)
|
||||
|
||||
+18
-44
@@ -27,7 +27,24 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
|
||||
if markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.include")
|
||||
end
|
||||
end
|
||||
|
||||
def parse(_tokens)
|
||||
@@ -84,49 +101,6 @@ module Liquid
|
||||
alias_method :parse_context, :options
|
||||
private :parse_context
|
||||
|
||||
def rigid_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@template_name_expr = safe_parse_expression(p)
|
||||
@variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with")
|
||||
@alias_name = p.consume(:id) if p.id?("as")
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ SYNTAX
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.include")
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[
|
||||
|
||||
@@ -7,10 +7,6 @@ module Liquid
|
||||
# @liquid_name increment
|
||||
# @liquid_summary
|
||||
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_description
|
||||
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
|
||||
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
|
||||
|
||||
+28
-68
@@ -27,7 +27,7 @@ module Liquid
|
||||
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
|
||||
class Render < Tag
|
||||
FOR = 'for'
|
||||
SYNTAX = /(#{QuotedString}+|#{VariableSegment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
||||
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
||||
|
||||
disable_tags "include"
|
||||
|
||||
@@ -35,7 +35,22 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@alias_name = Regexp.last_match(6)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
|
||||
@attributes = {}
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
end
|
||||
|
||||
def for_loop?
|
||||
@@ -47,23 +62,21 @@ module Liquid
|
||||
end
|
||||
|
||||
def render_tag(context, output)
|
||||
template = context.evaluate(@template_name_expr)
|
||||
# The expression should be a String literal, which parses to a String object
|
||||
template_name = @template_name_expr
|
||||
raise ::ArgumentError unless template_name.is_a?(String)
|
||||
|
||||
if template.respond_to?(:to_partial)
|
||||
partial = template.to_partial
|
||||
template_name = template.filename
|
||||
context_variable_name = @alias_name || template.name
|
||||
elsif @template_name_expr.is_a?(String)
|
||||
partial = PartialCache.load(template, context: context, parse_context: parse_context)
|
||||
template_name = partial.name
|
||||
context_variable_name = @alias_name || template_name.split('/').last
|
||||
else
|
||||
raise ::ArgumentError
|
||||
end
|
||||
partial = PartialCache.load(
|
||||
template_name,
|
||||
context: context,
|
||||
parse_context: parse_context,
|
||||
)
|
||||
|
||||
context_variable_name = @alias_name || template_name.split('/').last
|
||||
|
||||
render_partial_func = ->(var, forloop) {
|
||||
inner_context = context.new_isolated_subcontext
|
||||
inner_context.template_name = template_name
|
||||
inner_context.template_name = partial.name
|
||||
inner_context.partial = true
|
||||
inner_context['forloop'] = forloop if forloop
|
||||
|
||||
@@ -86,59 +99,6 @@ module Liquid
|
||||
output
|
||||
end
|
||||
|
||||
# render (string) (with|for expression)? (as id)? (key: value)*
|
||||
def rigid_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@template_name_expr = parse_expression(rigid_template_name(p), safe: true)
|
||||
with_or_for = p.id?("for") || p.id?("with")
|
||||
@variable_name_expr = safe_parse_expression(p) if with_or_for
|
||||
@alias_name = p.consume(:id) if p.id?("as")
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma) # optional comma
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def rigid_template_name(p)
|
||||
return p.consume(:string) if p.look(:string)
|
||||
return p.consume(:id) if p.look(:id)
|
||||
|
||||
found = p.consume || "nothing"
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@alias_name = Regexp.last_match(6)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
|
||||
@attributes = {}
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# @liquid_public_docs
|
||||
# @liquid_type tag
|
||||
# @liquid_category variable
|
||||
# @liquid_name snippet
|
||||
# @liquid_summary
|
||||
# Creates a new inline snippet.
|
||||
# @liquid_description
|
||||
# You can create inline snippets to make your Liquid code more modular.
|
||||
# @liquid_syntax
|
||||
# {% snippet snippet_name %}
|
||||
# value
|
||||
# {% endsnippet %}
|
||||
class Snippet < Block
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
p = @parse_context.new_parser(markup)
|
||||
if p.look(:id)
|
||||
@to = p.consume(:id)
|
||||
p.consume(:end_of_string)
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.snippet")
|
||||
end
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
snippet_drop = SnippetDrop.new(@body, @to, context.template_name)
|
||||
context.scopes.last[@to] = snippet_drop
|
||||
context.resource_limits.increment_assign_score(assign_score_of(snippet_drop))
|
||||
output
|
||||
end
|
||||
|
||||
def blank?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_score_of(snippet_drop)
|
||||
snippet_drop.body.nodelist.sum { |node| node.to_s.bytesize }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -19,54 +19,17 @@ module Liquid
|
||||
# @liquid_syntax_keyword variable The current item in the array.
|
||||
# @liquid_syntax_keyword array The array to iterate over.
|
||||
# @liquid_syntax_keyword expression The expression to render.
|
||||
# @liquid_optional_param cols: [number] The number of columns that the table should have.
|
||||
# @liquid_optional_param limit: [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param cols [number] The number of columns that the table should have.
|
||||
# @liquid_optional_param limit [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
|
||||
class TableRow < Block
|
||||
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
|
||||
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
|
||||
|
||||
attr_reader :variable_name, :collection_name, :attributes
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def rigid_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@variable_name = p.consume(:id)
|
||||
|
||||
unless p.id?("in")
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in")
|
||||
end
|
||||
|
||||
@collection_name = safe_parse_expression(p)
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
unless ALLOWED_ATTRIBUTES.include?(key)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key)
|
||||
end
|
||||
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@variable_name = Regexp.last_match(1)
|
||||
@collection_name = parse_expression(Regexp.last_match(2))
|
||||
|
||||
+64
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
module Liquid
|
||||
# Templates are central to liquid.
|
||||
# Interpreting templates is a two step process. First you compile the
|
||||
# Interpretating templates is a two step process. First you compile the
|
||||
# source code you got. During compile time some extensive error checking is performed.
|
||||
# your code should expect to get some SyntaxErrors.
|
||||
#
|
||||
@@ -24,8 +24,7 @@ module Liquid
|
||||
# Sets how strict the parser should be.
|
||||
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
|
||||
# :warn is the default and will give deprecation warnings when invalid syntax is used.
|
||||
# :strict enforces correct syntax for most tags
|
||||
# :rigid enforces correct syntax for all tags
|
||||
# :strict will enforce correct syntax.
|
||||
def error_mode=(mode)
|
||||
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
|
||||
Environment.default.error_mode = mode
|
||||
@@ -206,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)
|
||||
|
||||
+2
-5
@@ -2,9 +2,6 @@
|
||||
|
||||
module Liquid
|
||||
module Utils
|
||||
DECIMAL_REGEX = /\A-?\d+\.\d+\z/
|
||||
UNIX_TIMESTAMP_REGEX = /\A\d+\z/
|
||||
|
||||
def self.slice_collection(collection, from, to)
|
||||
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
|
||||
collection.load_slice(from, to)
|
||||
@@ -55,7 +52,7 @@ module Liquid
|
||||
when Numeric
|
||||
obj
|
||||
when String
|
||||
DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
||||
/\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
||||
else
|
||||
if obj.respond_to?(:to_number)
|
||||
obj.to_number
|
||||
@@ -76,7 +73,7 @@ module Liquid
|
||||
case obj
|
||||
when 'now', 'today'
|
||||
Time.now
|
||||
when UNIX_TIMESTAMP_REGEX, Integer
|
||||
when /\A\d+\z/, Integer
|
||||
Time.at(obj.to_i)
|
||||
when String
|
||||
Time.parse(obj)
|
||||
|
||||
+4
-55
@@ -54,7 +54,7 @@ module Liquid
|
||||
next unless f =~ /\w+/
|
||||
filtername = Regexp.last_match(0)
|
||||
filterargs = f.scan(FilterArgsRegex).flatten
|
||||
@filters << lax_parse_filter_expressions(filtername, filterargs)
|
||||
@filters << parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -65,26 +65,15 @@ module Liquid
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@name = parse_context.safe_parse_expression(p)
|
||||
@name = parse_context.parse_expression(p.expression)
|
||||
while p.consume?(:pipe)
|
||||
filtername = p.consume(:id)
|
||||
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
|
||||
@filters << lax_parse_filter_expressions(filtername, filterargs)
|
||||
@filters << parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def rigid_parse(markup)
|
||||
@filters = []
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@name = parse_context.safe_parse_expression(p)
|
||||
@filters << rigid_parse_filter_expressions(p) while p.consume?(:pipe)
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def parse_filterargs(p)
|
||||
# first argument
|
||||
filterargs = [p.argument]
|
||||
@@ -133,7 +122,7 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def lax_parse_filter_expressions(filter_name, unparsed_args)
|
||||
def parse_filter_expressions(filter_name, unparsed_args)
|
||||
filter_args = []
|
||||
keyword_args = nil
|
||||
unparsed_args.each do |a|
|
||||
@@ -149,46 +138,6 @@ module Liquid
|
||||
result
|
||||
end
|
||||
|
||||
# Surprisingly, positional and keyword arguments can be mixed.
|
||||
#
|
||||
# filter = filtername [":" filterargs?]
|
||||
# filterargs = argument ("," argument)*
|
||||
# argument = (positional_argument | keyword_argument)
|
||||
# positional_argument = expression
|
||||
# keyword_argument = id ":" expression
|
||||
def rigid_parse_filter_expressions(p)
|
||||
filtername = p.consume(:id)
|
||||
filter_args = []
|
||||
keyword_args = {}
|
||||
|
||||
if p.consume?(:colon)
|
||||
# Parse first argument (no leading comma)
|
||||
argument(p, filter_args, keyword_args) unless end_of_arguments?(p)
|
||||
|
||||
# Parse remaining arguments (with leading commas) and optional trailing comma
|
||||
argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p)
|
||||
end
|
||||
|
||||
result = [filtername, filter_args]
|
||||
result << keyword_args unless keyword_args.empty?
|
||||
result
|
||||
end
|
||||
|
||||
def argument(p, positional_arguments, keyword_arguments)
|
||||
if p.look(:id) && p.look(:colon, 1)
|
||||
key = p.consume(:id)
|
||||
p.consume(:colon)
|
||||
value = parse_context.safe_parse_expression(p)
|
||||
keyword_arguments[key] = value
|
||||
else
|
||||
positional_arguments << parse_context.safe_parse_expression(p)
|
||||
end
|
||||
end
|
||||
|
||||
def end_of_arguments?(p)
|
||||
p.look(:pipe) || p.look(:end_of_string)
|
||||
end
|
||||
|
||||
def evaluate_filter_expressions(context, filter_args, filter_kwargs)
|
||||
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
|
||||
if filter_kwargs
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
VERSION = "5.10.0"
|
||||
VERSION = "5.8.6"
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -632,7 +632,7 @@ class ContextTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_has_key_will_not_add_an_error_for_missing_keys
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
context = Context.new
|
||||
context.key?('unknown')
|
||||
assert_empty(context.errors)
|
||||
|
||||
@@ -67,7 +67,7 @@ class ErrorHandlingTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unrecognized_operator
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
|
||||
end
|
||||
|
||||
@@ -26,12 +26,8 @@ class ExpressionTest < Minitest::Test
|
||||
def test_float
|
||||
assert_template_result("-17.42", "{{ -17.42 }}")
|
||||
assert_template_result("2.5", "{{ 2.5 }}")
|
||||
|
||||
with_error_modes(:lax) do
|
||||
assert_expression_result(0.0, "0.....5")
|
||||
assert_expression_result(0.0, "-0..1")
|
||||
end
|
||||
|
||||
assert_expression_result(0.0, "0.....5")
|
||||
assert_expression_result(0.0, "-0..1")
|
||||
assert_expression_result(1.5, "1.5")
|
||||
|
||||
# this is a unfortunate quirky behavior of Liquid
|
||||
@@ -65,7 +61,6 @@ class ExpressionTest < Minitest::Test
|
||||
assert_template_result(
|
||||
"",
|
||||
"{{ - 'theme.css' - }}",
|
||||
error_mode: :lax,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -152,35 +147,6 @@ class ExpressionTest < Minitest::Test
|
||||
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
|
||||
end
|
||||
|
||||
def test_safe_parse_with_variable_lookup
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('product.title')
|
||||
result = Liquid::Expression.safe_parse(parser)
|
||||
|
||||
assert_instance_of(Liquid::VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
def test_safe_parse_with_number
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('42')
|
||||
result = Liquid::Expression.safe_parse(parser)
|
||||
|
||||
assert_equal(42, result)
|
||||
end
|
||||
|
||||
def test_safe_parse_raises_syntax_error_for_invalid_expression
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('')
|
||||
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Expression.safe_parse(parser)
|
||||
end
|
||||
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_expression_result(expect, markup, **assigns)
|
||||
|
||||
@@ -31,18 +31,18 @@ class ParsingQuirksTest < Minitest::Test
|
||||
def test_error_on_empty_filter
|
||||
assert(Template.parse("{{test}}"))
|
||||
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert(Template.parse("{{|test}}"))
|
||||
end
|
||||
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
|
||||
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_meaningless_parens_error
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
|
||||
Template.parse("{% if #{markup} %} YES {% endif %}")
|
||||
@@ -51,7 +51,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unexpected_characters_syntax_error
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
markup = "true && false"
|
||||
Template.parse("{% if #{markup} %} YES {% endif %}")
|
||||
@@ -70,7 +70,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_meaningless_parens_lax
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assigns = { 'b' => 'bar', 'c' => 'baz' }
|
||||
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
|
||||
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
|
||||
@@ -78,7 +78,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unexpected_characters_silently_eat_logic_lax
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
markup = "true && false"
|
||||
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
|
||||
markup = "false || true"
|
||||
@@ -93,7 +93,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unanchored_filter_arguments
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
|
||||
|
||||
assert_template_result('x', "{{ 'X' | downcase) }}")
|
||||
@@ -106,14 +106,14 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_invalid_variables_work
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
|
||||
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
|
||||
end
|
||||
end
|
||||
|
||||
def test_extra_dots_in_ranges
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
|
||||
end
|
||||
end
|
||||
@@ -133,7 +133,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_incomplete_expression
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result("false", "{{ false - }}")
|
||||
assert_template_result("false", "{{ false > }}")
|
||||
assert_template_result("false", "{{ false < }}")
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
require 'test_helper'
|
||||
|
||||
class CycleTagTest < Minitest::Test
|
||||
def test_simple_cycle
|
||||
template = <<~LIQUID
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_simple_cycle_inside_for_loop
|
||||
template = <<~LIQUID
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{% cycle '1', '2', '3' %}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
@@ -26,157 +36,13 @@ class CycleTagTest < Minitest::Test
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_cycle_named_groups_string
|
||||
template = <<~LIQUID
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle 'placeholder1': 1, 2, 3 -%}
|
||||
{%- cycle 'placeholder2': 1, 2, 3 -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112233", template)
|
||||
end
|
||||
|
||||
def test_cycle_named_groups_vlookup
|
||||
template = <<~LIQUID
|
||||
{%- assign placeholder1 = 'placeholder1' -%}
|
||||
{%- assign placeholder2 = 'placeholder2' -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle placeholder1: 1, 2, 3 -%}
|
||||
{%- cycle placeholder2: 1, 2, 3 -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112233", template)
|
||||
end
|
||||
|
||||
def test_unnamed_cycle_have_independent_counters_when_used_with_lookups
|
||||
def test_cycle_tag_always_resets_cycle
|
||||
template = <<~LIQUID
|
||||
{%- assign a = "1" -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- endfor -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112211", template)
|
||||
end
|
||||
|
||||
def test_unnamed_cycle_dependent_counter_when_used_with_literal_values
|
||||
template = <<~LIQUID
|
||||
{%- cycle "1", "2" -%}
|
||||
{%- cycle "1", "2" -%}
|
||||
{%- cycle "1", "2" -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("121", template)
|
||||
end
|
||||
|
||||
def test_optional_trailing_comma
|
||||
template = <<~LIQUID
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("1211", template)
|
||||
end
|
||||
|
||||
def test_cycle_tag_without_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% cycle %}")
|
||||
end
|
||||
|
||||
assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
|
||||
end
|
||||
|
||||
def test_cycle_tag_with_error_mode
|
||||
# QuotedFragment is more permissive than what Parser#expression allows.
|
||||
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
|
||||
template2 = "{% cycle .5: 'a', 'b' %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("b", template1)
|
||||
assert_template_result("a", template2)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||
|
||||
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
|
||||
|
||||
assert_match(expected_error, error1.message)
|
||||
assert_match(expected_error, error2.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_with_trailing_elements
|
||||
assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
|
||||
|
||||
template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
|
||||
template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}"
|
||||
template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}"
|
||||
template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}"
|
||||
template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("a", template1)
|
||||
assert_template_result("a", template2)
|
||||
assert_template_result("a", template3)
|
||||
assert_template_result("N", template4)
|
||||
assert_template_result("N", template5)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
|
||||
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
|
||||
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
|
||||
|
||||
expected_error = /Expected end_of_string but found/
|
||||
|
||||
assert_match(expected_error, error1.message)
|
||||
assert_match(expected_error, error2.message)
|
||||
assert_match(expected_error, error3.message)
|
||||
assert_match(expected_error, error4.message)
|
||||
assert_match(expected_error, error5.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_name_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{% for i in (1..3) %}
|
||||
{% cycle foo=>bar: "a", "b" %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_variable_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{% for i in (1..3) %}
|
||||
{% cycle foo=>bar, "a", "b" %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
assert_template_result("11", template)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -204,32 +204,6 @@ class IncludeTagTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_rigid_parsing_errors
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(
|
||||
'hello value1 value2',
|
||||
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
|
||||
)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_syntax_error(
|
||||
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
)
|
||||
assert_syntax_error(
|
||||
'{% include "snippet" | filter %}',
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_optional_commas
|
||||
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
|
||||
assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
|
||||
end
|
||||
|
||||
def test_include_tag_caches_second_read_of_same_partial
|
||||
file_system = CountingFileSystem.new
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
@@ -303,13 +277,13 @@ class IncludeTagTest < Minitest::Test
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
|
||||
end
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
|
||||
end
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
|
||||
end
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
|
||||
end
|
||||
end
|
||||
@@ -400,43 +374,4 @@ class IncludeTagTest < Minitest::Test
|
||||
render_errors: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_include_template_with_invalid_expression
|
||||
template = "{% include foo=>bar %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_with_invalid_expression
|
||||
template = '{% include "snippet" with foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_attribute_with_invalid_expression
|
||||
template = '{% include "snippet", key: foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end # IncludeTagTest
|
||||
|
||||
@@ -101,33 +101,11 @@ class RenderTagTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
|
||||
def test_rigid_parsing_errors
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(
|
||||
'hello value1 value2',
|
||||
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
|
||||
)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_syntax_error(
|
||||
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
)
|
||||
assert_syntax_error(
|
||||
'{% render "snippet" | filter %}',
|
||||
)
|
||||
end
|
||||
def test_dynamically_choosen_templates_are_not_allowed
|
||||
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
|
||||
end
|
||||
|
||||
def test_optional_commas
|
||||
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
|
||||
assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
|
||||
end
|
||||
|
||||
def test_render_tag_caches_second_read_of_same_partial
|
||||
def test_include_tag_caches_second_read_of_same_partial
|
||||
file_system = StubFileSystem.new('snippet' => 'echo')
|
||||
assert_equal(
|
||||
'echoecho',
|
||||
@@ -290,13 +268,6 @@ class RenderTagTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_tag_with_snippet_drop
|
||||
assert_template_result(
|
||||
"Hello from snippet",
|
||||
"{% snippet my_snippet %}Hello from snippet{% endsnippet %}{% render my_snippet %}",
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_tag_renders_error_with_template_name
|
||||
assert_template_result(
|
||||
'Liquid error (foo line 1): standard error',
|
||||
@@ -317,30 +288,4 @@ class RenderTagTest < Minitest::Test
|
||||
render_errors: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_with_invalid_expression
|
||||
template = '{% render "snippet" with foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_render_attribute_with_invalid_expression
|
||||
template = '{% render "snippet", key: foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,7 +138,7 @@ class TableRowTest < Minitest::Test
|
||||
|
||||
def test_tablerow_loop_drop_attributes
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..2) %}
|
||||
{% tablerow i in (1...2) %}
|
||||
col: {{ tablerowloop.col }}
|
||||
col0: {{ tablerowloop.col0 }}
|
||||
col_first: {{ tablerowloop.col_first }}
|
||||
@@ -192,14 +192,12 @@ class TableRowTest < Minitest::Test
|
||||
assert_template_result(
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
|
||||
error_mode: :warn,
|
||||
render_errors: true,
|
||||
)
|
||||
|
||||
assert_template_result(
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
|
||||
error_mode: :warn,
|
||||
render_errors: true,
|
||||
)
|
||||
|
||||
@@ -207,19 +205,18 @@ class TableRowTest < Minitest::Test
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
|
||||
render_errors: true,
|
||||
error_mode: :warn,
|
||||
)
|
||||
end
|
||||
|
||||
def test_table_row_handles_interrupts
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
|
||||
'{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
|
||||
'{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
|
||||
)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td><td class=\"col2\"> 2 </td></tr>\n<tr class=\"row2\"><td class=\"col1\"> 3 </td></tr>\n",
|
||||
'{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
|
||||
'{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
|
||||
)
|
||||
end
|
||||
|
||||
@@ -258,211 +255,4 @@ class TableRowTest < Minitest::Test
|
||||
template,
|
||||
)
|
||||
end
|
||||
|
||||
def test_tablerow_with_cols_attribute_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
<tr class="row2"><td class="col1">4</td><td class="col2">5</td><td class="col3">6</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_limit_attribute_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_offset_attribute_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_range_attribute_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_multiple_attributes_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">2</td><td class="col2">3</td></tr>
|
||||
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_variable_collection_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td></tr>
|
||||
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_dotted_access_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td></tr>
|
||||
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_bracketed_access_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">10</td><td class="col2">20</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_without_attributes_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_without_in_keyword_in_rigid_mode
|
||||
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_multiple_invalid_attributes_reports_first_in_rigid_mode
|
||||
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_empty_collection_in_rigid_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
</tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result(expected, template, { 'empty_array' => [] })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_invalid_attribute_strict_vs_rigid
|
||||
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td><td class="col4">4</td><td class="col5">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_invalid_expression_strict_vs_rigid
|
||||
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
</tr>
|
||||
OUTPUT
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -209,69 +209,4 @@ class VariableTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_with_single_trailing_comma
|
||||
template = '{{ "hello" | append: "world", }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result('helloworld', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_multiple_filters_with_trailing_commas
|
||||
template = '{{ "hello" | append: "1", | append: "2", }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result('hello12', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_with_colon_but_no_arguments
|
||||
template = '{{ "test" | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result('TEST', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_chain_with_colon_no_args
|
||||
template = '{{ "test" | append: "x" | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result('TESTX', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_combining_trailing_comma_and_empty_args
|
||||
template = '{{ "test" | append: "x", | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
assert_template_result('TESTX', template)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+4
-6
@@ -34,7 +34,7 @@ module Minitest
|
||||
|
||||
def assert_template_result(
|
||||
expected, template, assigns = {},
|
||||
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
|
||||
message: nil, partials: nil, error_mode: nil, render_errors: false,
|
||||
template_factory: nil
|
||||
)
|
||||
file_system = StubFileSystem.new(partials || {})
|
||||
@@ -82,12 +82,10 @@ module Minitest
|
||||
Environment.dangerously_override(environment, &blk)
|
||||
end
|
||||
|
||||
def with_error_modes(*modes)
|
||||
def with_error_mode(mode)
|
||||
old_mode = Liquid::Environment.default.error_mode
|
||||
modes.each do |mode|
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
yield
|
||||
end
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
yield
|
||||
ensure
|
||||
Liquid::Environment.default.error_mode = old_mode
|
||||
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
|
||||
@@ -166,37 +166,6 @@ class ConditionUnitTest < Minitest::Test
|
||||
assert_includes(err.lines.map(&:strip), expected)
|
||||
end
|
||||
|
||||
def test_parse_expression_in_strict_mode
|
||||
environment = Environment.build(error_mode: :strict)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
result = Condition.parse_expression(parse_context, 'product.title')
|
||||
|
||||
assert_instance_of(VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
def test_parse_expression_in_rigid_mode_raises_internal_error
|
||||
environment = Environment.build(error_mode: :rigid)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
Condition.parse_expression(parse_context, 'product.title')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_safe_true_in_rigid_mode
|
||||
environment = Environment.build(error_mode: :rigid)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
result = Condition.parse_expression(parse_context, 'product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_evaluates_true(left, op, right)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class EnvironmentTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
class UnsubscribeFooter < Liquid::Tag
|
||||
def render(_context)
|
||||
'Unsubscribe Footer'
|
||||
end
|
||||
end
|
||||
|
||||
def test_custom_tag
|
||||
email_environment = Liquid::Environment.build do |environment|
|
||||
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
|
||||
end
|
||||
|
||||
assert(email_environment.tags["unsubscribe_footer"])
|
||||
assert(email_environment.tag_for_name("unsubscribe_footer"))
|
||||
template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment)
|
||||
|
||||
assert_equal('Unsubscribe Footer', template.render)
|
||||
end
|
||||
end
|
||||
@@ -1,123 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class ParseContextUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_safe_parse_expression_with_variable_lookup
|
||||
parser_strict = strict_parse_context.new_parser('product.title')
|
||||
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
|
||||
|
||||
parser_rigid = rigid_parse_context.new_parser('product.title')
|
||||
result_rigid = rigid_parse_context.safe_parse_expression(parser_rigid)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
assert_instance_of(VariableLookup, result_rigid)
|
||||
assert_equal('product', result_rigid.name)
|
||||
assert_equal(['title'], result_rigid.lookups)
|
||||
end
|
||||
|
||||
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
|
||||
parser_strict = strict_parse_context.new_parser('')
|
||||
parser_rigid = rigid_parse_context.new_parser('')
|
||||
|
||||
error_strict = assert_raises(Liquid::SyntaxError) do
|
||||
strict_parse_context.safe_parse_expression(parser_strict)
|
||||
end
|
||||
assert_match(/is not a valid expression/, error_strict.message)
|
||||
|
||||
error_rigid = assert_raises(Liquid::SyntaxError) do
|
||||
rigid_parse_context.safe_parse_expression(parser_rigid)
|
||||
end
|
||||
|
||||
assert_match(/is not a valid expression/, error_rigid.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_variable_lookup
|
||||
result_strict = strict_parse_context.parse_expression('product.title')
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
rigid_parse_context.parse_expression('product.title')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_safe_true
|
||||
result_strict = strict_parse_context.parse_expression('product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
result_rigid = rigid_parse_context.parse_expression('product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result_rigid)
|
||||
assert_equal('product', result_rigid.name)
|
||||
assert_equal(['title'], result_rigid.lookups)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_empty_string
|
||||
result_strict = strict_parse_context.parse_expression('')
|
||||
assert_nil(result_strict)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
rigid_parse_context.parse_expression('')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_empty_string_and_safe_true
|
||||
result_strict = strict_parse_context.parse_expression('', safe: true)
|
||||
assert_nil(result_strict)
|
||||
|
||||
result_rigid = rigid_parse_context.parse_expression('', safe: true)
|
||||
assert_nil(result_rigid)
|
||||
end
|
||||
|
||||
def test_safe_parse_expression_advances_parser_pointer
|
||||
parser = rigid_parse_context.new_parser('foo, bar')
|
||||
|
||||
# safe_parse_expression consumes "foo"
|
||||
first_result = rigid_parse_context.safe_parse_expression(parser)
|
||||
assert_instance_of(VariableLookup, first_result)
|
||||
assert_equal('foo', first_result.name)
|
||||
|
||||
parser.consume(:comma)
|
||||
|
||||
# safe_parse_expression consumes "bar"
|
||||
second_result = rigid_parse_context.safe_parse_expression(parser)
|
||||
assert_instance_of(VariableLookup, second_result)
|
||||
assert_equal('bar', second_result.name)
|
||||
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_whitespace_in_rigid_mode
|
||||
result = rigid_parse_context.parse_expression(' ', safe: true)
|
||||
assert_nil(result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strict_parse_context
|
||||
@strict_parse_context ||= ParseContext.new(
|
||||
environment: Environment.build(error_mode: :strict),
|
||||
)
|
||||
end
|
||||
|
||||
def rigid_parse_context
|
||||
@rigid_parse_context ||= ParseContext.new(
|
||||
environment: Environment.build(error_mode: :rigid),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -184,7 +184,7 @@ class PartialCacheUnitTest < Minitest::Test
|
||||
},
|
||||
)
|
||||
|
||||
[:lax, :warn, :strict, :rigid].each do |error_mode|
|
||||
[:lax, :warn, :strict].each do |error_mode|
|
||||
Liquid::PartialCache.load(
|
||||
'my_partial',
|
||||
context: context,
|
||||
@@ -193,7 +193,7 @@ class PartialCacheUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
assert_equal(
|
||||
["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:rigid"],
|
||||
["my_partial:lax", "my_partial:warn", "my_partial:strict"],
|
||||
context.registers[:cached_partials].keys,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -9,120 +9,4 @@ class CaseTagUnitTest < Minitest::Test
|
||||
template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}')
|
||||
assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten)
|
||||
end
|
||||
|
||||
def test_case_with_trailing_element
|
||||
template = <<~LIQUID
|
||||
{%- case 1 bar -%}
|
||||
{%- when 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Expected end_of_string but found/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_trailing_element
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 1 bar -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Expected end_of_string but found/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_comma
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 2, 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :rigid) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_or
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 2 or 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :rigid) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{%- case foo=>bar -%}
|
||||
{%- when 'baz' -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
assigns = { 'foo' => { 'bar' => 'baz' } }
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template, assigns)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{%- case 'baz' -%}
|
||||
{%- when foo=>bar -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
assigns = { 'foo' => { 'bar' => 'baz' } }
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template, assigns)
|
||||
end
|
||||
|
||||
with_error_modes(:rigid) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,21 +20,6 @@ class DocTagUnitTest < Minitest::Test
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_body_content
|
||||
doc_content = " Documentation content\n @param {string} foo - test\n"
|
||||
template_source = "{% doc %}#{doc_content}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(doc_content, doc_tag.nodelist.first.to_s)
|
||||
end
|
||||
|
||||
def test_doc_tag_does_not_support_extra_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
template = <<~LIQUID.chomp
|
||||
@@ -131,20 +116,6 @@ class DocTagUnitTest < Minitest::Test
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_captures_token_before_enddoc
|
||||
template_source = "{% doc %}{{ incomplete{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s)
|
||||
end
|
||||
|
||||
def test_doc_tag_preserves_error_line_numbers
|
||||
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
|
||||
{% doc %}
|
||||
@@ -174,11 +145,11 @@ class DocTagUnitTest < Minitest::Test
|
||||
|
||||
def test_doc_tag_delimiter_handling
|
||||
assert_template_result('', <<~LIQUID.chomp)
|
||||
{%- if true -%}
|
||||
{%- doc -%}
|
||||
{%- docEXTRA -%}wut{% enddocEXTRA -%}xyz
|
||||
{%- enddoc -%}
|
||||
{%- endif -%}
|
||||
{% if true %}
|
||||
{% doc %}
|
||||
{% docEXTRA %}wut{% enddocEXTRA %}xyz
|
||||
{% enddoc %}
|
||||
{% endif %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
|
||||
@@ -196,80 +167,6 @@ class DocTagUnitTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_empty_content
|
||||
template_source = "{% doc %}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(true, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_content
|
||||
template_source = "{% doc %}Some documentation{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(false, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_whitespace_only
|
||||
template_source = "{% doc %} {% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(false, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_nodelist_returns_array_with_body
|
||||
doc_content = "Documentation content\n@param {string} foo"
|
||||
template_source = "{% doc %}#{doc_content}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal([doc_content], doc_tag.nodelist)
|
||||
assert_equal(1, doc_tag.nodelist.length)
|
||||
assert_equal(doc_content, doc_tag.nodelist.first)
|
||||
end
|
||||
|
||||
def test_doc_tag_nodelist_with_empty_content
|
||||
template_source = "{% doc %}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal([""], doc_tag.nodelist)
|
||||
assert_equal(1, doc_tag.nodelist.length)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def traversal(template)
|
||||
|
||||
@@ -108,7 +108,7 @@ class VariableUnitTest < Minitest::Test
|
||||
assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name)
|
||||
assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name)
|
||||
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
|
||||
@@ -135,68 +135,16 @@ class VariableUnitTest < Minitest::Test
|
||||
var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax)
|
||||
assert_equal(VariableLookup.new('number_of_comments'), var.name)
|
||||
assert_equal([['pluralize', ['comment', 'comments']]], var.filters)
|
||||
|
||||
# missing does not throws error
|
||||
create_variable(%(n | f1: ,), error_mode: :lax)
|
||||
create_variable(%(n | f1: ,| f2), error_mode: :lax)
|
||||
|
||||
# arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':'
|
||||
var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax)
|
||||
assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters)
|
||||
|
||||
# positional and kwargs parsing
|
||||
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax)
|
||||
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs intermixed (pos1, key1: val1, pos2)
|
||||
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax)
|
||||
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
|
||||
end
|
||||
|
||||
def test_strict_filter_argument_parsing
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_rigid_filter_argument_parsing
|
||||
with_error_modes(:rigid) do
|
||||
# optional colon
|
||||
var = create_variable(%(n | f1 | f2:))
|
||||
assert_equal([['f1', []], ['f2', []]], var.filters)
|
||||
|
||||
# missing argument throws error
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
|
||||
|
||||
# arg requires colon
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
|
||||
|
||||
# trailing comma doesn't throw
|
||||
create_variable(%(n | f1: 1, 2, 3, | f2:))
|
||||
|
||||
# missing comma throws error
|
||||
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
|
||||
|
||||
# positional and kwargs parsing
|
||||
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
|
||||
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs mixed
|
||||
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
|
||||
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs intermixed (pos1, key1: val1, pos2)
|
||||
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
|
||||
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
|
||||
|
||||
# string key throws
|
||||
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
|
||||
end
|
||||
end
|
||||
|
||||
def test_output_raw_source_of_variable
|
||||
var = create_variable(%( name_of_variable | upcase ))
|
||||
assert_equal(" name_of_variable | upcase ", var.raw)
|
||||
|
||||
Reference in New Issue
Block a user