mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-15 00:40:40 -07:00
Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9367b8b32e | ||
|
|
5cdbce7d6e | ||
|
|
520e86b8c8 | ||
|
|
54e3d2328a | ||
|
|
7be2922f91 | ||
|
|
e0f856fd11 | ||
|
|
c6f05eaf83 | ||
|
|
eabbd5cb6f | ||
|
|
ea864f1177 | ||
|
|
c34dd812c5 | ||
|
|
cc04892e54 | ||
|
|
f676e699a4 | ||
|
|
fc0f7f44c1 | ||
|
|
b459eb656f | ||
|
|
c6dcf3e714 | ||
|
|
4dae678c63 | ||
|
|
4c07ff920b | ||
|
|
dbe709c3bf | ||
|
|
2b75bfaff4 | ||
|
|
87bc6e7cfa | ||
|
|
7f122aeed2 | ||
|
|
aa1640035f | ||
|
|
f5d6a36574 | ||
|
|
c5711c095f | ||
|
|
284f5fb647 | ||
|
|
21432928d0 | ||
|
|
1783c0c084 | ||
|
|
e38f730c00 | ||
|
|
2d0442798b | ||
|
|
6453a0ea48 | ||
|
|
a398b4cc74 | ||
|
|
cca9fe99cf | ||
|
|
17d327988d | ||
|
|
f643af4bac | ||
|
|
ae8a0a86ac | ||
|
|
b439d0da53 | ||
|
|
16592cfb8f | ||
|
|
da4afd4156 | ||
|
|
1bb3091208 | ||
|
|
040801b32c | ||
|
|
550135c0b9 | ||
|
|
aec966eed7 | ||
|
|
bfe29e11be | ||
|
|
f9454d8cf3 | ||
|
|
8dd9279265 | ||
|
|
bf1419b8ac | ||
|
|
5718c4cee2 | ||
|
|
b0dbc62696 | ||
|
|
03aafa974c | ||
|
|
6372289ba3 | ||
|
|
0ec52a40b5 | ||
|
|
74af735f0e | ||
|
|
4b65a28722 | ||
|
|
ecf25ea83d | ||
|
|
6909570f8e | ||
|
|
cd9971579f | ||
|
|
8e37c5e18b | ||
|
|
b3f9639e7d | ||
|
|
fe3da0e17d | ||
|
|
e200c4544b | ||
|
|
7124540563 | ||
|
|
0558bd12c4 | ||
|
|
0639a094a8 | ||
|
|
58777bcd93 | ||
|
|
323951b36f | ||
|
|
a5b91e83ae | ||
|
|
10114b333e | ||
|
|
a07ae90523 | ||
|
|
2e236b0a0e | ||
|
|
bd05dfbd1c | ||
|
|
2a5ecf068f | ||
|
|
0b8e30b819 | ||
|
|
97cada61f9 | ||
|
|
7c9b77e8cf | ||
|
|
d48708ae46 | ||
|
|
42d822bda9 | ||
|
|
2c7d686690 | ||
|
|
313d01706a | ||
|
|
e9f86724f6 | ||
|
|
0a17c15289 | ||
|
|
04bd9dbe90 | ||
|
|
253ec81b56 | ||
|
|
e05719fb82 | ||
|
|
ac374a208f | ||
|
|
91be3dd75e | ||
|
|
40191022e8 | ||
|
|
71506ad54e | ||
|
|
cef64e277e | ||
|
|
7c592c1c00 | ||
|
|
002e4caea7 | ||
|
|
3c16c27ee1 | ||
|
|
eff5c5de8e | ||
|
|
19adfbd863 | ||
|
|
a672f3836c | ||
|
|
92fa334192 | ||
|
|
7c8a269b4d | ||
|
|
0b1dc295ff | ||
|
|
1de6025362 | ||
|
|
8ecb703d4d | ||
|
|
b4667adadf | ||
|
|
94e02d765f | ||
|
|
60701f865d | ||
|
|
0c49dd592f | ||
|
|
c3ac0e0127 | ||
|
|
a0a4307e7d | ||
|
|
fdd8c714b2 | ||
|
|
63583ffe5b | ||
|
|
9a06cedbba | ||
|
|
347a2418c4 |
@@ -13,8 +13,22 @@ jobs:
|
||||
entry:
|
||||
- { ruby: 3.0, allowed-failure: false } # minimum supported
|
||||
- { ruby: 3.2, allowed-failure: false }
|
||||
- { ruby: 3.3, allowed-failure: false } # latest
|
||||
- { ruby: 3.3, allowed-failure: false }
|
||||
- { ruby: 3.3, allowed-failure: false }
|
||||
- { ruby: 3.4, allowed-failure: false } # latest
|
||||
- {
|
||||
ruby: 3.4,
|
||||
allowed-failure: false,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
|
||||
- { ruby: ruby-head, allowed-failure: false }
|
||||
- {
|
||||
ruby: ruby-head,
|
||||
allowed-failure: false,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
|
||||
name: Test Ruby ${{ matrix.entry.ruby }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -25,6 +39,8 @@ jobs:
|
||||
bundler: latest
|
||||
- run: bundle exec rake
|
||||
continue-on-error: ${{ matrix.entry.allowed-failure }}
|
||||
env:
|
||||
RUBYOPT: ${{ matrix.entry.rubyopt }}
|
||||
|
||||
memory_profile:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.3.4
|
||||
3.4.1
|
||||
|
||||
@@ -26,3 +26,11 @@
|
||||
* If it makes sense, add tests for your code and/or run a performance benchmark
|
||||
* Make sure all tests pass (`bundle exec rake`)
|
||||
* Create a pull request
|
||||
|
||||
## Releasing
|
||||
|
||||
* Bump the version in `lib/liquid/version.rb`
|
||||
* Update the `History.md` file
|
||||
* Open a PR like [this one](https://github.com/Shopify/liquid/pull/1894) and merge it to `main`
|
||||
* Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new)
|
||||
|
||||
|
||||
@@ -13,18 +13,19 @@ group :benchmark, :test do
|
||||
gem 'benchmark-ips'
|
||||
gem 'memory_profiler'
|
||||
gem 'terminal-table'
|
||||
gem "lru_redux"
|
||||
|
||||
install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do
|
||||
gem 'stackprof'
|
||||
end
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem "webrick"
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem 'rubocop', '~> 1.61.0'
|
||||
gem 'rubocop-shopify', '~> 2.12.0', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
|
||||
platform :mri, :truffleruby do
|
||||
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'main'
|
||||
end
|
||||
end
|
||||
|
||||
+71
@@ -1,5 +1,76 @@
|
||||
# Liquid Change Log
|
||||
|
||||
## 5.8.1 (unreleased)
|
||||
|
||||
## 5.8.1
|
||||
|
||||
* Fix `{% doc %}` tag to be visitable [Guilherme Carreiro]
|
||||
|
||||
## 5.8.0
|
||||
|
||||
* Introduce the new `{% doc %}` tag [Guilherme Carreiro]
|
||||
|
||||
## 5.7.3
|
||||
|
||||
* Raise Liquid::SyntaxError when parsing invalidly encoded strings [Chris AtLee]
|
||||
|
||||
## 5.7.2 2025-01-31
|
||||
|
||||
* Fix array filters to not support nested properties [Guilherme Carreiro]
|
||||
|
||||
## 5.7.1 2025-01-24
|
||||
|
||||
* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays [Guilherme Carreiro]
|
||||
* Fix the `has` filter to return `false` when filtering empty arrays [Guilherme Carreiro]
|
||||
|
||||
## 5.7.0 2025-01-16
|
||||
|
||||
### Features
|
||||
|
||||
* Add `find`, `find_index`, `has`, and `reject` filters to arrays [Guilherme Carreiro]
|
||||
* Compatibility with Ruby 3.4 [Ian Ker-Seymer]
|
||||
|
||||
## 5.6.4 2025-01-14
|
||||
|
||||
### Fixes
|
||||
* Add a default `string_scanner` to avoid errors with `Liquid::VariableLookup.parse("foo.bar")` [Ian Ker-Seymer]
|
||||
|
||||
## 5.6.3 2025-01-13
|
||||
* Remove `lru_redux` dependency [Michael Go]
|
||||
|
||||
## 5.6.2 2025-01-13
|
||||
|
||||
### Fixes
|
||||
* Preserve the old behavior of requiring floats to start with a digit [Michael Go]
|
||||
|
||||
## 5.6.1 2025-01-13
|
||||
|
||||
### Performance improvements
|
||||
* Faster Expression parser / Tokenizer with StringScanner [Michael Go]
|
||||
|
||||
## 5.6.0 2024-12-19
|
||||
|
||||
### Architectural changes
|
||||
* Added new `Environment` class to manage configuration and state that was previously stored in `Template` [Ian Ker-Seymer]
|
||||
* Moved tag registration from `Template` to `Environment` [Ian Ker-Seymer]
|
||||
* Removed `StrainerFactory` in favor of `Environment`-based strainer creation [Ian Ker-Seymer]
|
||||
* Consolidated standard tags into a new `Tags` module with `STANDARD_TAGS` constant [Ian Ker-Seymer]
|
||||
|
||||
### Performance improvements
|
||||
* Optimized `Lexer` with a new `Lexer2` implementation using jump tables for faster tokenization, requires Ruby 3.4 [Ian Ker-Seymer]
|
||||
* Improved variable rendering with specialized handling for different types [Michael Go]
|
||||
* Reduced array allocations by using frozen empty constants [Michael Go]
|
||||
|
||||
### API changes
|
||||
* Deprecated several `Template` class methods in favor of `Environment` methods [Ian Ker-Seymer]
|
||||
* Added deprecation warnings system [Ian Ker-Seymer]
|
||||
* Changed how filters and tags are registered to use Environment [Ian Ker-Seymer]
|
||||
|
||||
### Fixes
|
||||
* Fixed table row handling of break interrupts [Alex Coco]
|
||||
* Improved variable output handling for arrays [Ian Ker-Seymer]
|
||||
* Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar]
|
||||
|
||||
## 5.5.0 2024-03-21
|
||||
|
||||
Please reference the GitHub release for more information.
|
||||
|
||||
@@ -91,7 +91,7 @@ Liquid::Template.parse(<<~LIQUID, environment: email_environment)
|
||||
LIQUID
|
||||
```
|
||||
|
||||
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage.
|
||||
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
|
||||
|
||||
### Error Modes
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ task :test do
|
||||
Rake::Task['base_test'].invoke
|
||||
|
||||
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
|
||||
ENV['LIQUID_C'] = '1'
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'lax'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
@@ -73,7 +71,7 @@ end
|
||||
|
||||
namespace :benchmark do
|
||||
desc "Run the liquid benchmark with lax parsing"
|
||||
task :run do
|
||||
task :lax do
|
||||
ruby "./performance/benchmark.rb lax"
|
||||
end
|
||||
|
||||
@@ -82,11 +80,30 @@ namespace :benchmark do
|
||||
ruby "./performance/benchmark.rb strict"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with both lax and strict parsing"
|
||||
task run: [:lax, :strict]
|
||||
|
||||
desc "Run unit benchmarks"
|
||||
task :unit do
|
||||
Dir["./performance/unit/*_benchmark.rb"].each do |file|
|
||||
puts "🧪 Running #{file}"
|
||||
ruby file
|
||||
namespace :unit do
|
||||
task :all do
|
||||
Dir["./performance/unit/*_benchmark.rb"].each do |file|
|
||||
puts "🧪 Running #{file}"
|
||||
ruby file
|
||||
end
|
||||
end
|
||||
|
||||
task :lexer do
|
||||
Dir["./performance/unit/lexer_benchmark.rb"].each do |file|
|
||||
puts "🧪 Running #{file}"
|
||||
ruby file
|
||||
end
|
||||
end
|
||||
|
||||
task :expression do
|
||||
Dir["./performance/unit/expression_benchmark.rb"].each do |file|
|
||||
puts "🧪 Running #{file}"
|
||||
ruby file
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+5
-1
@@ -21,6 +21,8 @@
|
||||
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
require "strscan"
|
||||
|
||||
module Liquid
|
||||
FilterSeparator = /\|/
|
||||
ArgumentSeparator = ','
|
||||
@@ -44,6 +46,7 @@ module Liquid
|
||||
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
|
||||
|
||||
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
|
||||
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
|
||||
end
|
||||
|
||||
require "liquid/version"
|
||||
@@ -68,7 +71,6 @@ require 'liquid/extensions'
|
||||
require 'liquid/errors'
|
||||
require 'liquid/interrupts'
|
||||
require 'liquid/strainer_template'
|
||||
require 'liquid/expression'
|
||||
require 'liquid/context'
|
||||
require 'liquid/tag'
|
||||
require 'liquid/block_body'
|
||||
@@ -77,6 +79,7 @@ require 'liquid/variable'
|
||||
require 'liquid/variable_lookup'
|
||||
require 'liquid/range_lookup'
|
||||
require 'liquid/resource_limits'
|
||||
require 'liquid/expression'
|
||||
require 'liquid/template'
|
||||
require 'liquid/condition'
|
||||
require 'liquid/utils'
|
||||
@@ -86,3 +89,4 @@ require 'liquid/partial_cache'
|
||||
require 'liquid/usage'
|
||||
require 'liquid/registers'
|
||||
require 'liquid/template_factory'
|
||||
require 'liquid/box'
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid::Box - Secure sandboxed execution environment for compiled Liquid templates
|
||||
#
|
||||
# On Ruby 4.0+, this uses the native Ruby::Box for true isolation.
|
||||
# On earlier Ruby versions, this provides a polyfill that WARNS about insecurity.
|
||||
#
|
||||
# == SECURITY WARNING
|
||||
#
|
||||
# The polyfill on Ruby < 4.0 provides NO REAL SECURITY. It's a compatibility shim
|
||||
# that allows code to run, but malicious templates could potentially escape.
|
||||
# Use Ruby 4.0+ in production for actual sandboxing.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello {{ name }}!")
|
||||
# compiled = template.compile_to_ruby
|
||||
#
|
||||
# # compiled is a Liquid::CompiledTemplate which wraps a Box
|
||||
# result = compiled.render({ "name" => "World" })
|
||||
# # => "Hello World!"
|
||||
#
|
||||
# # Access the generated Ruby code:
|
||||
# puts compiled.source
|
||||
#
|
||||
|
||||
module Liquid
|
||||
# Check if we have Ruby 4.0's native Box AND it's enabled
|
||||
# Ruby::Box may be defined but disabled (requires RUBY_BOX=1 env var before Ruby starts)
|
||||
RUBY_BOX_AVAILABLE = begin
|
||||
if defined?(Ruby::Box)
|
||||
# Try to create a box to see if it's actually enabled
|
||||
test_box = Ruby::Box.new
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
rescue RuntimeError => e
|
||||
# Ruby::Box exists but is disabled
|
||||
false
|
||||
end
|
||||
|
||||
unless RUBY_BOX_AVAILABLE
|
||||
# Warn once on load that we're using the insecure polyfill
|
||||
warn "[Liquid::Box] WARNING: Ruby::Box not available or disabled. " \
|
||||
"Using INSECURE polyfill. Compiled templates are NOT sandboxed! " \
|
||||
"(Ruby 4.0+ with RUBY_BOX=1 required for secure execution)" if $VERBOSE
|
||||
end
|
||||
|
||||
# Polyfill for Ruby::Box when not available (Ruby < 4.0)
|
||||
# This provides the same API but NO SECURITY - it just evals code in the main environment.
|
||||
module BoxPolyfill
|
||||
class Box
|
||||
def initialize
|
||||
@constants = {}
|
||||
@binding = TOPLEVEL_BINDING.dup
|
||||
end
|
||||
|
||||
def eval(code)
|
||||
::Kernel.eval(code, @binding)
|
||||
end
|
||||
|
||||
def const_get(name)
|
||||
::Object.const_get(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Liquid::Box wraps either Ruby::Box (secure) or BoxPolyfill::Box (insecure)
|
||||
#
|
||||
# This provides a secure sandboxed environment for executing compiled Liquid templates.
|
||||
# On Ruby 4.0+, templates run in true isolation with dangerous methods removed.
|
||||
# On Ruby < 4.0, templates run without sandboxing (development/testing only).
|
||||
#
|
||||
class Box
|
||||
attr_reader :box
|
||||
|
||||
class << self
|
||||
# Returns true if we have real sandboxing (Ruby 4.0+)
|
||||
def secure?
|
||||
RUBY_BOX_AVAILABLE
|
||||
end
|
||||
|
||||
# Create a pre-configured box for Liquid template execution.
|
||||
# This is the recommended way to get a Box instance.
|
||||
def create_for_liquid
|
||||
box = new
|
||||
box.load_liquid_runtime!
|
||||
box.lock!
|
||||
box
|
||||
end
|
||||
end
|
||||
|
||||
def initialize
|
||||
@box = RUBY_BOX_AVAILABLE ? Ruby::Box.new : BoxPolyfill::Box.new
|
||||
@locked = false
|
||||
@user_constants = []
|
||||
@warned_insecure = false
|
||||
end
|
||||
|
||||
# Load code into the sandbox before locking.
|
||||
# Use this to define filters, helpers, and the Liquid runtime.
|
||||
def load_runtime(code)
|
||||
raise "Cannot load runtime after lock!" if @locked
|
||||
before = @box.eval("Object.constants")
|
||||
@box.eval(code)
|
||||
after = @box.eval("Object.constants")
|
||||
@user_constants += (after - before).map(&:to_s)
|
||||
end
|
||||
|
||||
# Load the standard Liquid runtime helpers (LR module).
|
||||
# Call this before lock! to set up the execution environment.
|
||||
# The runtime provides all helper methods that compiled templates use.
|
||||
def load_liquid_runtime!
|
||||
raise "Cannot load runtime after lock!" if @locked
|
||||
|
||||
if RUBY_BOX_AVAILABLE
|
||||
# Add gem paths to box's load_path so it can find base64, bigdecimal, etc.
|
||||
# These are safe, side-effect-free libs that only provide pure functions
|
||||
setup_gem_load_paths!
|
||||
|
||||
# Load dependencies INTO the box - we need the actual libraries,
|
||||
# not reimplementations, to handle all edge cases correctly
|
||||
@box.require('cgi')
|
||||
@box.require('base64')
|
||||
@box.require('bigdecimal')
|
||||
@box.require('bigdecimal/util') # For String#to_d etc.
|
||||
|
||||
# Now load the runtime which captures method references from these
|
||||
@box.require(RUNTIME_PATH)
|
||||
else
|
||||
# Polyfill: just require it normally
|
||||
require 'cgi'
|
||||
require 'base64'
|
||||
require 'bigdecimal'
|
||||
require 'bigdecimal/util'
|
||||
require RUNTIME_PATH
|
||||
end
|
||||
|
||||
# Track constants to preserve after lock
|
||||
@user_constants << "LR"
|
||||
@user_constants << "LiquidRuntime"
|
||||
@user_constants << "CGI"
|
||||
@user_constants << "Base64"
|
||||
@user_constants << "BigDecimal"
|
||||
end
|
||||
|
||||
# Add gem paths to the box's load_path so require works for gems
|
||||
def setup_gem_load_paths!
|
||||
return unless RUBY_BOX_AVAILABLE
|
||||
|
||||
# Find gem paths from the main environment's $LOAD_PATH
|
||||
# and from Gem.path if available
|
||||
gem_lib_paths = []
|
||||
|
||||
# Method 1: Find from $LOAD_PATH entries containing "gems"
|
||||
$LOAD_PATH.each do |path|
|
||||
gem_lib_paths << path if path.include?('/gems/')
|
||||
end
|
||||
|
||||
# Method 2: Use Gem.path if available
|
||||
if defined?(Gem) && Gem.respond_to?(:path)
|
||||
Gem.path.each do |gem_path|
|
||||
Dir.glob("#{gem_path}/gems/*/lib").each do |lib_path|
|
||||
gem_lib_paths << lib_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add unique paths to box's load_path
|
||||
gem_lib_paths.uniq.each do |path|
|
||||
@box.load_path << path unless @box.load_path.include?(path)
|
||||
end
|
||||
end
|
||||
|
||||
# Lock the sandbox. After this:
|
||||
# - No more runtime can be loaded
|
||||
# - Dangerous methods are removed (on Ruby 4.0+)
|
||||
# - Templates can be compiled and executed
|
||||
def lock!
|
||||
return if @locked
|
||||
|
||||
if RUBY_BOX_AVAILABLE
|
||||
apply_sandbox!
|
||||
else
|
||||
warn_insecure!
|
||||
end
|
||||
|
||||
@locked = true
|
||||
end
|
||||
|
||||
def locked?
|
||||
@locked
|
||||
end
|
||||
|
||||
# Returns true if this box provides real security (Ruby 4.0+)
|
||||
def secure?
|
||||
RUBY_BOX_AVAILABLE
|
||||
end
|
||||
|
||||
# Evaluate code in the sandbox.
|
||||
# Use this to compile templates into the sandbox.
|
||||
def eval(code)
|
||||
raise "Must call lock! before eval" unless @locked
|
||||
warn_insecure! unless RUBY_BOX_AVAILABLE
|
||||
@box.eval(code)
|
||||
end
|
||||
|
||||
# Get a constant from the sandbox by name.
|
||||
def const_get(name)
|
||||
@box.const_get(name)
|
||||
end
|
||||
|
||||
def [](name)
|
||||
const_get(name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def warn_insecure!
|
||||
return if @warned_insecure
|
||||
@warned_insecure = true
|
||||
|
||||
$stderr.puts <<~WARNING
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ SECURITY WARNING: Liquid::Box running WITHOUT sandboxing ║
|
||||
║ ║
|
||||
║ Ruby::Box requires Ruby 4.0+. On earlier versions, compiled Liquid templates ║
|
||||
║ execute with FULL Ruby capabilities. This is NOT SECURE for untrusted input. ║
|
||||
║ ║
|
||||
║ For production use with untrusted templates, upgrade to Ruby 4.0+. ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
WARNING
|
||||
end
|
||||
|
||||
# Apply sandbox restrictions (Ruby 4.0+ only)
|
||||
def apply_sandbox!
|
||||
neuter_file_system!
|
||||
neuter_process_control!
|
||||
neuter_concurrency!
|
||||
neuter_introspection!
|
||||
neuter_serialization!
|
||||
neuter_time!
|
||||
neuter_environment!
|
||||
neuter_kernel!
|
||||
neuter_basic_object!
|
||||
neuter_object!
|
||||
neuter_main_singleton!
|
||||
neuter_module!
|
||||
cleanup_globals!
|
||||
remove_dangerous_constants!
|
||||
end
|
||||
|
||||
def neuter_file_system!
|
||||
@box.eval(<<~'RUBY')
|
||||
class << File
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
class << IO
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
class << Dir
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
class IO
|
||||
[:read, :write, :gets, :puts, :print, :readline, :readlines, :getc, :getbyte,
|
||||
:sysread, :syswrite, :close, :eof, :eof?, :rewind, :seek].each do |m|
|
||||
undef_method(m) rescue nil
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_process_control!
|
||||
@box.eval(<<~'RUBY')
|
||||
class << Process
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
class << Signal
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_concurrency!
|
||||
@box.eval(<<~'RUBY')
|
||||
class << Thread
|
||||
[:new, :start, :fork, :kill, :exit, :pass, :stop, :main, :current, :list,
|
||||
:abort_on_exception, :abort_on_exception=].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
class << Fiber
|
||||
[:new, :yield, :current].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
if defined?(Ractor)
|
||||
class << Ractor
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_introspection!
|
||||
@box.eval(<<~'RUBY')
|
||||
class << ObjectSpace
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
class << GC
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
if defined?(RubyVM)
|
||||
class << RubyVM
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
if defined?(RubyVM::InstructionSequence)
|
||||
class << RubyVM::InstructionSequence
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
end
|
||||
end
|
||||
class << TracePoint
|
||||
[:new, :stat, :trace].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_serialization!
|
||||
@box.eval(<<~'RUBY')
|
||||
class << Marshal
|
||||
[:dump, :load, :restore].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_time!
|
||||
# Time is neutered by default for security.
|
||||
# Templates that need time should receive it via assigns.
|
||||
@box.eval(<<~'RUBY')
|
||||
class << Time
|
||||
[:now, :new, :at, :mktime, :local, :utc, :gm].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_environment!
|
||||
@box.eval(<<~'RUBY')
|
||||
ENV.clear rescue nil
|
||||
class << ENV
|
||||
instance_methods(false).each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_kernel!
|
||||
@box.eval(<<~'RUBY')
|
||||
module Kernel
|
||||
[:eval, :`, :system, :exec, :spawn, :fork, :binding,
|
||||
:open, :require, :require_relative, :load, :autoload, :autoload?,
|
||||
:gets, :readline, :readlines, :select, :test,
|
||||
:trap, :exit, :exit!, :abort, :at_exit, :syscall, :sleep,
|
||||
:puts, :print, :printf, :putc, :p, :pp, :warn,
|
||||
:caller, :caller_locations, :set_trace_func, :trace_var, :untrace_var,
|
||||
:global_variables, :local_variables,
|
||||
:gem, :gem_original_require, :Pathname,
|
||||
].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
|
||||
class << Kernel
|
||||
[:eval, :`, :system, :exec, :spawn, :fork, :binding,
|
||||
:open, :require, :require_relative, :load,
|
||||
:puts, :print, :p, :pp, :warn,
|
||||
].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_basic_object!
|
||||
@box.eval(<<~'RUBY')
|
||||
class BasicObject
|
||||
undef_method(:instance_eval) rescue nil
|
||||
undef_method(:instance_exec) rescue nil
|
||||
undef_method(:__send__) rescue nil
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_object!
|
||||
@box.eval(<<~'RUBY')
|
||||
class Object
|
||||
[:gem, :gem_original_require, :require, :require_relative, :load,
|
||||
:display, :define_singleton_method,
|
||||
:instance_variable_set, :remove_instance_variable,
|
||||
:extend, :send, :public_send,
|
||||
].each { |m| undef_method(m) rescue nil }
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_main_singleton!
|
||||
# 'using' is defined on main's singleton class, must remove before undef_method is gone
|
||||
@box.eval(<<~'RUBY')
|
||||
class << self
|
||||
undef_method(:using) rescue nil
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def neuter_module!
|
||||
@box.eval(<<~'RUBY')
|
||||
class Module
|
||||
undef_method(:refine) rescue nil
|
||||
undef_method(:using) rescue nil
|
||||
undef_method(:const_set) rescue nil
|
||||
undef_method(:remove_const) rescue nil
|
||||
undef_method(:include) rescue nil
|
||||
undef_method(:prepend) rescue nil
|
||||
undef_method(:extend) rescue nil
|
||||
undef_method(:class_eval) rescue nil
|
||||
undef_method(:module_eval) rescue nil
|
||||
undef_method(:class_exec) rescue nil
|
||||
undef_method(:module_exec) rescue nil
|
||||
undef_method(:define_method) rescue nil
|
||||
undef_method(:alias_method) rescue nil
|
||||
undef_method(:module_function) rescue nil
|
||||
undef_method(:prepend_features) rescue nil
|
||||
undef_method(:append_features) rescue nil
|
||||
undef_method(:extend_object) rescue nil
|
||||
undef_method(:public) rescue nil
|
||||
undef_method(:private) rescue nil
|
||||
undef_method(:protected) rescue nil
|
||||
undef_method(:attr) rescue nil
|
||||
undef_method(:attr_reader) rescue nil
|
||||
undef_method(:attr_writer) rescue nil
|
||||
undef_method(:attr_accessor) rescue nil
|
||||
# Remove escape hatches LAST
|
||||
undef_method(:remove_method) rescue nil
|
||||
undef_method(:undef_method) rescue nil
|
||||
undef_method(:send) rescue nil
|
||||
undef_method(:public_send) rescue nil
|
||||
end
|
||||
|
||||
class Class
|
||||
undef_method(:send) rescue nil
|
||||
undef_method(:public_send) rescue nil
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
def cleanup_globals!
|
||||
@box.eval(<<~'RUBY')
|
||||
$stdin = nil
|
||||
$stdout = nil
|
||||
$stderr = nil
|
||||
$LOAD_PATH.clear rescue nil
|
||||
$LOAD_PATH.freeze rescue nil
|
||||
$LOADED_FEATURES.clear rescue nil
|
||||
$LOADED_FEATURES.freeze rescue nil
|
||||
ARGV.clear rescue nil
|
||||
ARGV.freeze rescue nil
|
||||
RUBY
|
||||
end
|
||||
|
||||
def remove_dangerous_constants!
|
||||
keep = %w[
|
||||
BasicObject Object Module Class Kernel
|
||||
String Integer Float Numeric Rational Complex
|
||||
Array Hash Range Set
|
||||
Symbol Regexp MatchData
|
||||
TrueClass FalseClass NilClass
|
||||
Proc Method UnboundMethod
|
||||
Struct Data
|
||||
Comparable Enumerable Enumerator
|
||||
StandardError RuntimeError ArgumentError TypeError NameError
|
||||
NoMethodError KeyError IndexError StopIteration FrozenError
|
||||
ZeroDivisionError RangeError FloatDomainError LocalJumpError
|
||||
Math Random
|
||||
Exception SystemStackError
|
||||
] + @user_constants
|
||||
|
||||
@box.eval(<<~RUBY)
|
||||
_keep = #{keep.inspect}
|
||||
(Object.constants.map(&:to_s) - _keep).each do |c|
|
||||
Object.send(:remove_const, c.to_sym) rescue nil
|
||||
end
|
||||
(Kernel.constants.map(&:to_s) - _keep).each do |c|
|
||||
Kernel.send(:remove_const, c.to_sym) rescue nil
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
# Path to the runtime file that gets loaded into the sandbox
|
||||
RUNTIME_PATH = File.expand_path('compile/runtime.rb', __dir__)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Ruby Compiler
|
||||
#
|
||||
# This module provides the ability to compile Liquid templates to pure Ruby code.
|
||||
# The compiled code can be eval'd to create a proc that renders the template
|
||||
# without needing the Liquid library at runtime.
|
||||
#
|
||||
# ## Usage
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||
# ruby_code = template.compile_to_ruby
|
||||
# render_proc = eval(ruby_code)
|
||||
# result = render_proc.call({ "name" => "World" })
|
||||
# # => "Hello, World!"
|
||||
#
|
||||
# ## Optimization Opportunities
|
||||
#
|
||||
# The compiled Ruby code has several significant advantages over interpreted Liquid:
|
||||
#
|
||||
# 1. **No Context Object**: Variables are extracted directly from the assigns hash
|
||||
# and accessed without the Context abstraction layer.
|
||||
#
|
||||
# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby method
|
||||
# calls rather than going through context.invoke().
|
||||
#
|
||||
# 3. **No Resource Limits Tracking**: The compiled code doesn't track render
|
||||
# scores, write scores, or assign scores, eliminating per-node overhead.
|
||||
#
|
||||
# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead
|
||||
# of manually managing scope stacks.
|
||||
#
|
||||
# 5. **Direct String Concatenation**: Output is built with direct << operations.
|
||||
#
|
||||
# 6. **Native Control Flow**: break/continue use Ruby's throw/catch mechanism.
|
||||
#
|
||||
# 7. **No to_liquid Calls**: Values are used directly without conversion.
|
||||
#
|
||||
# 8. **No Profiling Hooks**: No profiler overhead in the generated code.
|
||||
#
|
||||
# 9. **No Exception Rendering**: Errors propagate naturally.
|
||||
#
|
||||
# ## Limitations
|
||||
#
|
||||
# - {% render %} and {% include %} tags require runtime support
|
||||
# - Custom tags need explicit compiler implementations
|
||||
# - Custom filters need to be available at runtime
|
||||
#
|
||||
module Liquid
|
||||
module Compile
|
||||
autoload :CompiledTemplate, 'liquid/compile/compiled_template'
|
||||
autoload :CompiledContext, 'liquid/compile/compiled_context'
|
||||
autoload :CodeGenerator, 'liquid/compile/code_generator'
|
||||
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
|
||||
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
|
||||
autoload :FilterCompiler, 'liquid/compile/filter_compiler'
|
||||
autoload :VariableCompiler, 'liquid/compile/variable_compiler'
|
||||
autoload :BlockBodyCompiler, 'liquid/compile/block_body_compiler'
|
||||
autoload :ConditionCompiler, 'liquid/compile/condition_compiler'
|
||||
autoload :SourceMapper, 'liquid/compile/source_mapper'
|
||||
|
||||
module Tags
|
||||
autoload :IfCompiler, 'liquid/compile/tags/if_compiler'
|
||||
autoload :UnlessCompiler, 'liquid/compile/tags/unless_compiler'
|
||||
autoload :CaseCompiler, 'liquid/compile/tags/case_compiler'
|
||||
autoload :ForCompiler, 'liquid/compile/tags/for_compiler'
|
||||
autoload :AssignCompiler, 'liquid/compile/tags/assign_compiler'
|
||||
autoload :CaptureCompiler, 'liquid/compile/tags/capture_compiler'
|
||||
autoload :CycleCompiler, 'liquid/compile/tags/cycle_compiler'
|
||||
autoload :IncrementCompiler, 'liquid/compile/tags/increment_compiler'
|
||||
autoload :DecrementCompiler, 'liquid/compile/tags/decrement_compiler'
|
||||
autoload :RawCompiler, 'liquid/compile/tags/raw_compiler'
|
||||
autoload :EchoCompiler, 'liquid/compile/tags/echo_compiler'
|
||||
autoload :BreakCompiler, 'liquid/compile/tags/break_compiler'
|
||||
autoload :ContinueCompiler, 'liquid/compile/tags/continue_compiler'
|
||||
autoload :CommentCompiler, 'liquid/compile/tags/comment_compiler'
|
||||
autoload :TableRowCompiler, 'liquid/compile/tags/tablerow_compiler'
|
||||
autoload :RenderCompiler, 'liquid/compile/tags/render_compiler'
|
||||
autoload :IncludeCompiler, 'liquid/compile/tags/include_compiler'
|
||||
autoload :IfchangedCompiler, 'liquid/compile/tags/ifchanged_compiler'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# Represents a compiled Liquid template ready for execution.
|
||||
#
|
||||
# Contains the Ruby source code and any external tags/filters that need to be
|
||||
# passed to the generated lambda at runtime.
|
||||
#
|
||||
# Usage:
|
||||
# compiled = template.compile_to_ruby
|
||||
# result = compiled.call({ "name" => "World" })
|
||||
#
|
||||
# # With custom filters:
|
||||
# compiled.filter_handler = MyFilterModule
|
||||
# result = compiled.call({ "name" => "World" })
|
||||
#
|
||||
class CompiledTemplate
|
||||
attr_reader :code, :external_tags
|
||||
attr_accessor :filter_handler
|
||||
|
||||
# @param code [String] The generated Ruby code
|
||||
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
|
||||
# @param has_external_filters [Boolean] Whether external filters are used
|
||||
def initialize(code, external_tags = {}, has_external_filters = false)
|
||||
@code = code
|
||||
@external_tags = external_tags
|
||||
@has_external_filters = has_external_filters
|
||||
@filter_handler = nil
|
||||
@proc = nil
|
||||
end
|
||||
|
||||
# Returns true if this template has external tags that need runtime delegation
|
||||
def has_external_tags?
|
||||
!@external_tags.empty?
|
||||
end
|
||||
|
||||
# Returns true if this template uses external filters
|
||||
def has_external_filters?
|
||||
@has_external_filters
|
||||
end
|
||||
|
||||
# Returns the compiled proc, caching it after first compilation
|
||||
def to_proc
|
||||
@proc ||= eval(@code)
|
||||
end
|
||||
|
||||
# Execute the compiled template with the given assigns
|
||||
# @param assigns [Hash] The variable assignments
|
||||
# @param filter_handler [Object] Optional filter handler to override the default
|
||||
# @param registers [Hash] Optional registers for context
|
||||
# @param strict_variables [Boolean] Raise on undefined variables
|
||||
# @param strict_filters [Boolean] Raise on undefined filters
|
||||
# @return [String] The rendered output
|
||||
def call(assigns = {}, filter_handler: nil, registers: {}, strict_variables: false, strict_filters: false)
|
||||
proc = to_proc
|
||||
handler = filter_handler || @filter_handler
|
||||
|
||||
# Create a context for Drop support
|
||||
context = CompiledContext.new(
|
||||
assigns,
|
||||
registers: registers,
|
||||
strict_variables: strict_variables,
|
||||
strict_filters: strict_filters
|
||||
)
|
||||
|
||||
# Build arguments based on what the lambda expects
|
||||
args = [assigns]
|
||||
args << @external_tags if has_external_tags?
|
||||
args << handler if has_external_filters?
|
||||
args << context # Always pass context as last arg
|
||||
|
||||
proc.call(*args)
|
||||
end
|
||||
|
||||
# Returns the Ruby code as a string
|
||||
def to_s
|
||||
@code
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Make CompiledTemplate available at the top level for convenience
|
||||
CompiledTemplate = Compile::CompiledTemplate
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# ConditionCompiler compiles Liquid conditions to Ruby boolean expressions.
|
||||
#
|
||||
# Handles:
|
||||
# - Simple truthiness: {% if variable %}
|
||||
# - Comparisons: {% if a == b %}, {% if a > b %}
|
||||
# - Logical operators: {% if a and b %}, {% if a or b %}
|
||||
# - Special checks: {% if a == blank %}, {% if a == empty %}
|
||||
class ConditionCompiler
|
||||
# Operator mappings from Liquid to Ruby
|
||||
OPERATORS = {
|
||||
'==' => '==',
|
||||
'!=' => '!=',
|
||||
'<>' => '!=',
|
||||
'<' => '<',
|
||||
'>' => '>',
|
||||
'<=' => '<=',
|
||||
'>=' => '>=',
|
||||
'contains' => :contains,
|
||||
}.freeze
|
||||
|
||||
# Compile a Condition to a Ruby boolean expression
|
||||
# @param condition [Liquid::Condition] The condition
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @return [String] Ruby code expression that evaluates to true/false
|
||||
def self.compile(condition, compiler)
|
||||
if condition.is_a?(ElseCondition)
|
||||
return "true"
|
||||
end
|
||||
|
||||
compile_condition_chain(condition, compiler)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.compile_condition_chain(condition, compiler)
|
||||
# Compile the current condition
|
||||
current = compile_single_condition(condition, compiler)
|
||||
|
||||
# Check for chained conditions (and/or)
|
||||
if condition.child_condition
|
||||
child = compile_condition_chain(condition.child_condition, compiler)
|
||||
child_relation = condition.send(:child_relation)
|
||||
|
||||
case child_relation
|
||||
when :and
|
||||
"(#{current} && #{child})"
|
||||
when :or
|
||||
"(#{current} || #{child})"
|
||||
else
|
||||
current
|
||||
end
|
||||
else
|
||||
current
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_single_condition(condition, compiler)
|
||||
left = condition.left
|
||||
op = condition.operator
|
||||
right = condition.right
|
||||
|
||||
# If no operator, just check truthiness
|
||||
if op.nil?
|
||||
left_expr = ExpressionCompiler.compile(left, compiler)
|
||||
return "__truthy__(#{left_expr})"
|
||||
end
|
||||
|
||||
# Compile left and right expressions
|
||||
left_expr = compile_condition_value(left, compiler)
|
||||
right_expr = compile_condition_value(right, compiler)
|
||||
|
||||
# Handle special operators
|
||||
case OPERATORS[op]
|
||||
when :contains
|
||||
compile_contains(left_expr, right_expr, compiler)
|
||||
when '=='
|
||||
compile_equality(left, right, left_expr, right_expr, compiler)
|
||||
when '!='
|
||||
"!(#{compile_equality(left, right, left_expr, right_expr, compiler)})"
|
||||
else
|
||||
# Standard comparison
|
||||
ruby_op = OPERATORS[op] || op
|
||||
"(#{left_expr} #{ruby_op} #{right_expr} rescue false)"
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_condition_value(expr, compiler)
|
||||
if expr.is_a?(Condition::MethodLiteral)
|
||||
# For blank/empty checks, we return a special marker
|
||||
# The equality handler will deal with this
|
||||
":__method_literal_#{expr.method_name}__"
|
||||
else
|
||||
ExpressionCompiler.compile(expr, compiler)
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_equality(left, right, left_expr, right_expr, compiler)
|
||||
# Handle blank/empty method literals
|
||||
if left.is_a?(Condition::MethodLiteral)
|
||||
method_name = left.method_name
|
||||
"(#{right_expr}.respond_to?(:#{method_name}) ? #{right_expr}.#{method_name} : nil)"
|
||||
elsif right.is_a?(Condition::MethodLiteral)
|
||||
method_name = right.method_name
|
||||
"(#{left_expr}.respond_to?(:#{method_name}) ? #{left_expr}.#{method_name} : nil)"
|
||||
else
|
||||
"(#{left_expr} == #{right_expr})"
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_contains(left_expr, right_expr, compiler)
|
||||
# The contains operator checks if left includes right
|
||||
# For strings, right is converted to a string
|
||||
"(lambda { |left, right| " \
|
||||
"return false if left.nil? || right.nil? || !left.respond_to?(:include?); " \
|
||||
"right = right.to_s if left.is_a?(String); " \
|
||||
"left.include?(right) rescue false " \
|
||||
"}.call(#{left_expr}, #{right_expr}))"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# ExpressionCompiler compiles Liquid expressions to Ruby code.
|
||||
#
|
||||
# Expressions include:
|
||||
# - Literals: nil, true, false, strings, numbers
|
||||
# - Variable lookups: foo, foo.bar, foo[0], foo["key"]
|
||||
# - Ranges: (1..10), (start..end)
|
||||
class ExpressionCompiler
|
||||
# Compile an expression to a Ruby code string
|
||||
# @param expr [Object] The parsed expression
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @return [String] Ruby code that evaluates to the expression value
|
||||
def self.compile(expr, compiler)
|
||||
case expr
|
||||
when nil
|
||||
"nil"
|
||||
when true
|
||||
"true"
|
||||
when false
|
||||
"false"
|
||||
when String
|
||||
expr.inspect
|
||||
when Integer, Float
|
||||
expr.inspect
|
||||
when Range
|
||||
"(#{expr.begin.inspect}..#{expr.end.inspect})"
|
||||
when VariableLookup
|
||||
compile_variable_lookup(expr, compiler)
|
||||
when RangeLookup
|
||||
compile_range_lookup(expr, compiler)
|
||||
when Condition::MethodLiteral
|
||||
# Handle blank/empty method literals
|
||||
compile_method_literal(expr, compiler)
|
||||
else
|
||||
raise CompileError, "Unknown expression type: #{expr.class} (#{expr.inspect})"
|
||||
end
|
||||
end
|
||||
|
||||
# Compile a variable lookup expression
|
||||
# @param lookup [VariableLookup] The variable lookup
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @return [String] Ruby code that evaluates to the variable value
|
||||
def self.compile_variable_lookup(lookup, compiler)
|
||||
# Start with the base variable
|
||||
name = lookup.name
|
||||
|
||||
# Handle dynamic name (expression in brackets)
|
||||
base = if name.is_a?(VariableLookup) || name.is_a?(RangeLookup)
|
||||
# Dynamic name like [expr].foo
|
||||
"assigns[#{compile(name, compiler)}]"
|
||||
elsif name.is_a?(String)
|
||||
"assigns[#{name.inspect}]"
|
||||
elsif name.is_a?(Integer)
|
||||
"assigns[#{name.inspect}]"
|
||||
else
|
||||
compile(name, compiler)
|
||||
end
|
||||
|
||||
# Apply each lookup in the chain
|
||||
lookup.lookups.each_with_index do |key, index|
|
||||
if key.is_a?(VariableLookup) || key.is_a?(RangeLookup)
|
||||
# Dynamic key like foo[expr]
|
||||
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
|
||||
elsif key.is_a?(Integer)
|
||||
# Numeric index like foo[0]
|
||||
base = "__lookup__.call(#{base}, #{key})"
|
||||
elsif key.is_a?(String)
|
||||
# Always use __lookup__ which tries key access first,
|
||||
# then falls back to method call for command methods (first, last, size)
|
||||
base = "__lookup__.call(#{base}, #{key.inspect})"
|
||||
else
|
||||
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
|
||||
end
|
||||
end
|
||||
|
||||
base
|
||||
end
|
||||
|
||||
# Compile a range lookup expression
|
||||
# @param range [RangeLookup] The range lookup
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @return [String] Ruby code that evaluates to the range
|
||||
def self.compile_range_lookup(range, compiler)
|
||||
start_expr = compile(range.start_obj, compiler)
|
||||
end_expr = compile(range.end_obj, compiler)
|
||||
|
||||
# Convert to integers and create range
|
||||
"(__to_integer__(#{start_expr})...__to_integer__(#{end_expr})).to_a"
|
||||
end
|
||||
|
||||
# Compile a method literal (blank/empty)
|
||||
def self.compile_method_literal(literal, compiler)
|
||||
# These are used in conditions like `if foo == blank`
|
||||
# They represent special method calls
|
||||
literal.to_s.inspect
|
||||
end
|
||||
|
||||
# Compile an expression for use in a condition
|
||||
# @param expr [Object] The expression
|
||||
# @param compiler [RubyCompiler] The main compiler
|
||||
# @return [String] Ruby code for the condition value
|
||||
def self.compile_for_condition(expr, compiler)
|
||||
if expr.is_a?(Condition::MethodLiteral)
|
||||
# Return the method name symbol for special handling
|
||||
":#{expr.method_name}"
|
||||
else
|
||||
compile(expr, compiler)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,233 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# FilterCompiler compiles Liquid filter chains to Ruby code.
|
||||
#
|
||||
# Filters are applied in sequence: {{ value | filter1: arg1 | filter2: arg2 }}
|
||||
# becomes a chain of method calls on the value.
|
||||
class FilterCompiler
|
||||
# Standard filters that map directly to Ruby methods or simple expressions
|
||||
SIMPLE_FILTERS = {
|
||||
'size' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:size) ? #{input}.size : 0)" },
|
||||
'downcase' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).downcase" },
|
||||
'upcase' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).upcase" },
|
||||
'capitalize' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).capitalize" },
|
||||
'strip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).strip" },
|
||||
'lstrip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).lstrip" },
|
||||
'rstrip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).rstrip" },
|
||||
'reverse' => ->(input, _args, _kwargs, _compiler) { "(#{input}.is_a?(Array) ? #{input}.reverse : __to_s__(#{input}).reverse)" },
|
||||
'first' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:first) ? #{input}.first : nil)" },
|
||||
'last' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:last) ? #{input}.last : nil)" },
|
||||
'uniq' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:uniq) ? #{input}.uniq : #{input})" },
|
||||
'compact' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:compact) ? #{input}.compact : #{input})" },
|
||||
'flatten' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:flatten) ? #{input}.flatten : #{input})" },
|
||||
'sort' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:sort) ? #{input}.sort : #{input})" },
|
||||
'abs' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).abs" },
|
||||
'ceil' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).ceil.to_i" },
|
||||
'floor' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).floor.to_i" },
|
||||
'escape' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escapeHTML(__to_s__(#{input})))" },
|
||||
'h' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escapeHTML(__to_s__(#{input})))" },
|
||||
'url_encode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escape(__to_s__(#{input})))" },
|
||||
'url_decode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.unescape(__to_s__(#{input})))" },
|
||||
'base64_encode' => ->(input, _args, _kwargs, _compiler) { "Base64.strict_encode64(__to_s__(#{input}))" },
|
||||
'base64_decode' => ->(input, _args, _kwargs, _compiler) { "Base64.strict_decode64(__to_s__(#{input}))" },
|
||||
'base64_url_safe_encode' => ->(input, _args, _kwargs, _compiler) { "Base64.urlsafe_encode64(__to_s__(#{input}))" },
|
||||
'base64_url_safe_decode' => ->(input, _args, _kwargs, _compiler) { "Base64.urlsafe_decode64(__to_s__(#{input}))" },
|
||||
'strip_html' => ->(input, _args, _kwargs, _compiler) {
|
||||
"__to_s__(#{input}).gsub(%r{<script.*?</script>|<!--.*?-->|<style.*?</style>}m, '').gsub(/<.*?>/m, '')"
|
||||
},
|
||||
'strip_newlines' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).gsub(/\\r?\\n/, '')" },
|
||||
'newline_to_br' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).gsub(/\\r?\\n/, \"<br />\\n\")" },
|
||||
}.freeze
|
||||
|
||||
# Filters with arguments that need special handling
|
||||
PARAMETERIZED_FILTERS = {
|
||||
'append' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"__to_s__(#{input}) + __to_s__(#{arg})"
|
||||
},
|
||||
'prepend' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"__to_s__(#{arg}) + __to_s__(#{input})"
|
||||
},
|
||||
'plus' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(__to_number__(#{input}) + __to_number__(#{arg}))"
|
||||
},
|
||||
'minus' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(__to_number__(#{input}) - __to_number__(#{arg}))"
|
||||
},
|
||||
'times' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(__to_number__(#{input}) * __to_number__(#{arg}))"
|
||||
},
|
||||
'divided_by' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(__to_number__(#{input}) / __to_number__(#{arg}))"
|
||||
},
|
||||
'modulo' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"(__to_number__(#{input}) % __to_number__(#{arg}))"
|
||||
},
|
||||
'round' => ->(input, args, _kwargs, compiler) {
|
||||
if args.empty?
|
||||
"__to_number__(#{input}).round.to_i"
|
||||
else
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"__to_number__(#{input}).round(__to_number__(#{arg}))"
|
||||
end
|
||||
},
|
||||
'at_least' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"[__to_number__(#{input}), __to_number__(#{arg})].max"
|
||||
},
|
||||
'at_most' => ->(input, args, _kwargs, compiler) {
|
||||
arg = compile_arg(args[0], compiler)
|
||||
"[__to_number__(#{input}), __to_number__(#{arg})].min"
|
||||
},
|
||||
'default' => ->(input, args, kwargs, compiler) {
|
||||
default_val = args.empty? ? "''" : compile_arg(args[0], compiler)
|
||||
allow_false = kwargs && kwargs['allow_false'] ? compile_arg(kwargs['allow_false'], compiler) : 'false'
|
||||
"(if #{allow_false} then (#{input}.nil? || (#{input}.respond_to?(:empty?) && #{input}.empty?)) else (!__truthy__(#{input}) || (#{input}.respond_to?(:empty?) && #{input}.empty?)) end) ? #{default_val} : #{input}"
|
||||
},
|
||||
'split' => ->(input, args, _kwargs, compiler) {
|
||||
pattern = args.empty? ? "' '" : compile_arg(args[0], compiler)
|
||||
"__to_s__(#{input}).split(__to_s__(#{pattern}))"
|
||||
},
|
||||
'join' => ->(input, args, _kwargs, compiler) {
|
||||
glue = args.empty? ? "' '" : compile_arg(args[0], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input}.map { |i| __to_s__(i) }.join(__to_s__(#{glue})) : __to_s__(#{input}))"
|
||||
},
|
||||
'replace' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
|
||||
"__to_s__(#{input}).gsub(__to_s__(#{string}), __to_s__(#{replacement}))"
|
||||
},
|
||||
'replace_first' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
|
||||
"__to_s__(#{input}).sub(__to_s__(#{string}), __to_s__(#{replacement}))"
|
||||
},
|
||||
'remove' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
"__to_s__(#{input}).gsub(__to_s__(#{string}), '')"
|
||||
},
|
||||
'remove_first' => ->(input, args, _kwargs, compiler) {
|
||||
string = compile_arg(args[0], compiler)
|
||||
"__to_s__(#{input}).sub(__to_s__(#{string}), '')"
|
||||
},
|
||||
'truncate' => ->(input, args, _kwargs, compiler) {
|
||||
length = args.empty? ? "50" : compile_arg(args[0], compiler)
|
||||
ellipsis = args.length > 1 ? compile_arg(args[1], compiler) : "'...'"
|
||||
var = compiler.generate_var_name("trunc")
|
||||
"(lambda { |#{var}_input, #{var}_len, #{var}_ell| #{var}_str = __to_s__(#{var}_input); #{var}_ell_str = __to_s__(#{var}_ell); #{var}_l = [#{var}_len.to_i - #{var}_ell_str.length, 0].max; #{var}_str.length > #{var}_len.to_i ? #{var}_str[0, #{var}_l] + #{var}_ell_str : #{var}_str }).call(#{input}, #{length}, #{ellipsis})"
|
||||
},
|
||||
'truncatewords' => ->(input, args, _kwargs, compiler) {
|
||||
words = args.empty? ? "15" : compile_arg(args[0], compiler)
|
||||
ellipsis = args.length > 1 ? compile_arg(args[1], compiler) : "'...'"
|
||||
"(lambda { |input, num_words, ell| words = __to_s__(input).split(' ', [num_words.to_i, 1].max + 1); words.length > [num_words.to_i, 1].max ? words[0, [num_words.to_i, 1].max].join(' ') + __to_s__(ell) : input.to_s }).call(#{input}, #{words}, #{ellipsis})"
|
||||
},
|
||||
'slice' => ->(input, args, _kwargs, compiler) {
|
||||
offset = compile_arg(args[0], compiler)
|
||||
length = args.length > 1 ? compile_arg(args[1], compiler) : "1"
|
||||
"(#{input}.is_a?(Array) ? (#{input}.slice(__to_integer__(#{offset}), __to_integer__(#{length})) || []) : (__to_s__(#{input}).slice(__to_integer__(#{offset}), __to_integer__(#{length})) || ''))"
|
||||
},
|
||||
'map' => ->(input, args, _kwargs, compiler) {
|
||||
property = compile_arg(args[0], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input}.map { |item| item.respond_to?(:[]) ? item[#{property}] : nil } : [])"
|
||||
},
|
||||
'where' => ->(input, args, _kwargs, compiler) {
|
||||
property = compile_arg(args[0], compiler)
|
||||
if args.length > 1
|
||||
target = compile_arg(args[1], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input}.select { |item| item.respond_to?(:[]) && item[#{property}] == #{target} } : [])"
|
||||
else
|
||||
"(#{input}.is_a?(Array) ? #{input}.select { |item| item.respond_to?(:[]) && __truthy__(item[#{property}]) } : [])"
|
||||
end
|
||||
},
|
||||
'reject' => ->(input, args, _kwargs, compiler) {
|
||||
property = compile_arg(args[0], compiler)
|
||||
if args.length > 1
|
||||
target = compile_arg(args[1], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input}.reject { |item| item.respond_to?(:[]) && item[#{property}] == #{target} } : [])"
|
||||
else
|
||||
"(#{input}.is_a?(Array) ? #{input}.reject { |item| item.respond_to?(:[]) && __truthy__(item[#{property}]) } : [])"
|
||||
end
|
||||
},
|
||||
'concat' => ->(input, args, _kwargs, compiler) {
|
||||
arr = compile_arg(args[0], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input} + (#{arr}.respond_to?(:to_ary) ? #{arr}.to_ary : []) : [])"
|
||||
},
|
||||
'sort_natural' => ->(input, args, _kwargs, compiler) {
|
||||
if args.empty?
|
||||
"(#{input}.is_a?(Array) ? #{input}.sort_by { |a| a.to_s.downcase } : #{input})"
|
||||
else
|
||||
property = compile_arg(args[0], compiler)
|
||||
"(#{input}.is_a?(Array) ? #{input}.sort_by { |a| a.respond_to?(:[]) ? a[#{property}].to_s.downcase : '' } : #{input})"
|
||||
end
|
||||
},
|
||||
'date' => ->(input, args, _kwargs, compiler) {
|
||||
format = compile_arg(args[0], compiler)
|
||||
# This is a simplified version - full date parsing is complex
|
||||
"(lambda { |input, fmt| return input if fmt.to_s.empty?; d = case input; when Time, Date, DateTime then input; when 'now', 'today' then Time.now; when /\\A\\d+\\z/, Integer then Time.at(input.to_i); when String then (Time.parse(input) rescue input); else input; end; d.respond_to?(:strftime) ? d.strftime(fmt.to_s) : input }.call(#{input}, #{format}))"
|
||||
},
|
||||
'escape_once' => ->(input, _args, _kwargs, _compiler) {
|
||||
"__to_s__(#{input}).gsub(/[\"><']|&(?!([a-zA-Z]+|(#\\d+));)/) { |c| {'&'=>'&', '>'=>'>', '<'=>'<', '\"'=>'"', \"'\"=>'''}[c] || c }"
|
||||
},
|
||||
}.freeze
|
||||
|
||||
# Compile a filter chain
|
||||
# @param input_expr [String] Ruby expression for the input value
|
||||
# @param filters [Array] Array of filter definitions [name, args, kwargs]
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @return [String] Ruby code that applies all filters
|
||||
def self.compile(input_expr, filters, compiler)
|
||||
result = input_expr
|
||||
|
||||
filters.each do |filter_name, filter_args, filter_kwargs|
|
||||
result = compile_filter(result, filter_name, filter_args || [], filter_kwargs, compiler)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Compile a single filter application
|
||||
def self.compile_filter(input, name, args, kwargs, compiler)
|
||||
if SIMPLE_FILTERS.key?(name)
|
||||
SIMPLE_FILTERS[name].call(input, args, kwargs, compiler)
|
||||
elsif PARAMETERIZED_FILTERS.key?(name)
|
||||
PARAMETERIZED_FILTERS[name].call(input, args, kwargs, compiler)
|
||||
else
|
||||
# Fall back to a generic filter call
|
||||
compile_generic_filter(input, name, args, kwargs, compiler)
|
||||
end
|
||||
end
|
||||
|
||||
# Compile a filter that's not built-in
|
||||
# Uses __call_filter__ helper which must be provided by the runtime
|
||||
def self.compile_generic_filter(input, name, args, kwargs, compiler)
|
||||
# Mark that we're using external filters
|
||||
compiler.register_external_filter
|
||||
|
||||
compiled_args = args.map { |arg| compile_arg(arg, compiler) }
|
||||
|
||||
if kwargs && !kwargs.empty?
|
||||
kwargs_hash = kwargs.map { |k, v| "#{k.inspect} => #{compile_arg(v, compiler)}" }.join(", ")
|
||||
compiled_args << "{ #{kwargs_hash} }"
|
||||
end
|
||||
|
||||
args_str = compiled_args.empty? ? "[]" : "[#{compiled_args.join(', ')}]"
|
||||
|
||||
# Call through the filter helper which delegates to registered filters
|
||||
"__call_filter__.call(#{name.inspect}, #{input}, #{args_str})"
|
||||
end
|
||||
|
||||
# Compile a filter argument
|
||||
def self.compile_arg(arg, compiler)
|
||||
ExpressionCompiler.compile(arg, compiler)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,534 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# RubyCompiler transforms a parsed Liquid template into pure Ruby code.
|
||||
#
|
||||
# The compiled code is a string that can be eval'd to create a proc/lambda
|
||||
# that takes an assigns hash and returns the rendered output string.
|
||||
#
|
||||
# ## Optimization Opportunities
|
||||
#
|
||||
# The compiled Ruby code has several significant advantages over interpreted Liquid:
|
||||
#
|
||||
# 1. **No Context Object**: Instead of using a Context for variable lookups,
|
||||
# variables are extracted directly from the assigns hash and stored in
|
||||
# local Ruby variables. This eliminates hash lookups on every access.
|
||||
#
|
||||
# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby
|
||||
# method calls rather than going through context.invoke().
|
||||
#
|
||||
# 3. **No Resource Limits Tracking**: The compiled code doesn't track render
|
||||
# scores, write scores, or assign scores, eliminating per-node overhead.
|
||||
#
|
||||
# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead
|
||||
# of manually managing scope stacks with push/pop operations.
|
||||
#
|
||||
# 5. **Direct String Concatenation**: Output is built with direct << operations
|
||||
# rather than through render_to_output_buffer abstractions.
|
||||
#
|
||||
# 6. **Native Control Flow**: break/continue become Ruby's break/next,
|
||||
# eliminating interrupt objects and checks.
|
||||
#
|
||||
# 7. **No to_liquid Calls**: Values are used directly without conversion.
|
||||
#
|
||||
# 8. **Potential for Constant Folding**: Expressions with only literals
|
||||
# could be evaluated at compile time (future enhancement).
|
||||
#
|
||||
# 9. **Potential for Dead Code Elimination**: Unreachable branches like
|
||||
# `{% if false %}` could be removed (future enhancement).
|
||||
#
|
||||
# 10. **No Profiling Hooks**: No profiler overhead in the generated code.
|
||||
#
|
||||
# 11. **No Exception Rendering**: Errors propagate naturally rather than
|
||||
# being caught and rendered inline.
|
||||
#
|
||||
# ## Usage
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||
# ruby_code = template.compile_to_ruby
|
||||
# render_proc = eval(ruby_code)
|
||||
# result = render_proc.call({ "name" => "World" })
|
||||
# # => "Hello, World!"
|
||||
#
|
||||
class RubyCompiler
|
||||
attr_reader :template, :options
|
||||
|
||||
# @param template [Liquid::Template] The parsed template to compile
|
||||
# @param options [Hash] Compilation options
|
||||
# @option options [Boolean] :strict_variables Raise on undefined variables (default: false)
|
||||
# @option options [Boolean] :include_filters Include filter helper methods (default: true)
|
||||
# @option options [Boolean] :debug Emit source comments for debugging (default: false)
|
||||
# @option options [Object] :file_system File system for loading partials (default: template's environment)
|
||||
def initialize(template, options = {})
|
||||
@template = template
|
||||
@options = {
|
||||
strict_variables: false,
|
||||
include_filters: true,
|
||||
debug: false,
|
||||
file_system: nil,
|
||||
}.merge(options)
|
||||
@var_counter = 0
|
||||
@partials = {} # Registered partials: name => method_name
|
||||
@partial_sources = {} # Partial sources: name => source code
|
||||
@partial_counter = 0
|
||||
@external_tags = {} # External tags: var_name => tag object
|
||||
@external_tag_counter = 0
|
||||
@has_external_filters = false # Whether we need the filter helper
|
||||
end
|
||||
|
||||
# Mark that we have external filters
|
||||
def register_external_filter
|
||||
@has_external_filters = true
|
||||
end
|
||||
|
||||
# Check if external filters are used
|
||||
def has_external_filters?
|
||||
@has_external_filters
|
||||
end
|
||||
|
||||
# Register an external tag that will be called at runtime
|
||||
# @param tag [Liquid::Tag] The tag to register
|
||||
# @return [String] The variable name for this tag
|
||||
def register_external_tag(tag)
|
||||
@external_tag_counter += 1
|
||||
var_name = "__ext_tag_#{@external_tag_counter}__"
|
||||
@external_tags[var_name] = tag
|
||||
var_name
|
||||
end
|
||||
|
||||
# Get all registered external tags
|
||||
# @return [Hash] Map of variable names to tag objects
|
||||
def external_tags
|
||||
@external_tags
|
||||
end
|
||||
|
||||
# Get the file system for loading partials
|
||||
def file_system
|
||||
@options[:file_system] || @template.instance_variable_get(:@environment)&.file_system
|
||||
end
|
||||
|
||||
# Load a partial source from the file system
|
||||
# @param name [String] The partial name
|
||||
# @return [String, nil] The partial source or nil if not found
|
||||
def load_partial(name)
|
||||
return @partial_sources[name] if @partial_sources.key?(name)
|
||||
|
||||
fs = file_system
|
||||
return nil unless fs && fs.respond_to?(:read_template_file)
|
||||
|
||||
begin
|
||||
source = fs.read_template_file(name)
|
||||
@partial_sources[name] = source
|
||||
source
|
||||
rescue Liquid::FileSystemError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Register a partial and return its method name
|
||||
# @param name [String] The partial name
|
||||
# @param source [String] The partial source
|
||||
# @return [String] The method name for this partial
|
||||
def register_partial(name, source)
|
||||
return @partials[name] if @partials.key?(name)
|
||||
|
||||
@partial_counter += 1
|
||||
method_name = "__partial_#{@partial_counter}__"
|
||||
@partials[name] = method_name
|
||||
method_name
|
||||
end
|
||||
|
||||
# Get all registered partials
|
||||
def registered_partials
|
||||
@partials
|
||||
end
|
||||
|
||||
# Check if debug mode is enabled
|
||||
def debug?
|
||||
@options[:debug]
|
||||
end
|
||||
|
||||
# Emit a debug comment with source location info
|
||||
# This creates a lightweight source map that allows tracing errors
|
||||
# back to the original Liquid source
|
||||
def emit_debug_comment(code, node, description = nil)
|
||||
return unless debug?
|
||||
|
||||
line_number = node.respond_to?(:line_number) ? node.line_number : nil
|
||||
raw_markup = extract_raw_markup(node)
|
||||
|
||||
comment_parts = []
|
||||
comment_parts << "LIQUID"
|
||||
comment_parts << "L#{line_number}" if line_number
|
||||
comment_parts << description if description
|
||||
comment_parts << raw_markup.inspect if raw_markup && raw_markup.length < 80
|
||||
|
||||
code.line "# #{comment_parts.join(' | ')}"
|
||||
end
|
||||
|
||||
# Extract the raw markup from a node for debug output
|
||||
def extract_raw_markup(node)
|
||||
case node
|
||||
when Variable
|
||||
"{{ #{node.raw} }}"
|
||||
when Tag
|
||||
if node.respond_to?(:raw)
|
||||
"{% #{node.tag_name} #{node.raw} %}"
|
||||
elsif node.respond_to?(:markup)
|
||||
"{% #{node.tag_name} #{node.markup} %}"
|
||||
else
|
||||
"{% #{node.class.name.split('::').last.downcase} %}"
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Compile the template to a Ruby code string
|
||||
# @return [String] Ruby code that can be eval'd to create a render proc
|
||||
# @return [Hash] If external tags are used, returns { code: String, external_tags: Hash }
|
||||
def compile
|
||||
code = CodeGenerator.new
|
||||
|
||||
# Add debug header if enabled
|
||||
if debug?
|
||||
code.line "# Compiled from Liquid template: #{@template.name || '(unnamed)'}"
|
||||
code.line "# Debug mode enabled - comments contain source locations"
|
||||
code.line "# Format: # LIQUID | L<line> | <description> | <source>"
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# First pass: compile the document body to discover partials and external tags
|
||||
main_code = CodeGenerator.new
|
||||
compile_node(@template.root, main_code)
|
||||
|
||||
# Determine lambda parameters based on external dependencies
|
||||
params = ["assigns = {}"]
|
||||
params << "__external_tags__ = {}" unless @external_tags.empty?
|
||||
params << "__filter_handler__ = nil" if @has_external_filters
|
||||
params << "__context__ = nil"
|
||||
|
||||
code.line "->(#{params.join(', ')}) do"
|
||||
|
||||
code.indent do
|
||||
# Initialize the output buffer
|
||||
code.line '__output__ = +""'
|
||||
code.blank_line
|
||||
|
||||
# Create a compiled context if not provided (for Drop support)
|
||||
code.line "# Create context for Drop support"
|
||||
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
|
||||
code.blank_line
|
||||
|
||||
# Compile helper methods if needed
|
||||
if @options[:include_filters]
|
||||
compile_helper_methods(code)
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# Add external tag runtime helper if needed
|
||||
unless @external_tags.empty?
|
||||
compile_external_tag_helper(code)
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# Add external filter helper if needed
|
||||
if @has_external_filters
|
||||
compile_filter_helper(code)
|
||||
code.blank_line
|
||||
end
|
||||
|
||||
# Compile partial methods (before main body so they're available)
|
||||
compile_partials(code)
|
||||
|
||||
# Add the main body code
|
||||
code.raw(main_code.to_s)
|
||||
|
||||
code.blank_line
|
||||
code.line "__output__"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
code.to_s
|
||||
end
|
||||
|
||||
# Compile helper for calling external tags at runtime
|
||||
def compile_external_tag_helper(code)
|
||||
code.line "# Helper for calling external (unknown) tags at runtime"
|
||||
code.line "__call_external_tag__ = ->(tag_var, tag_assigns) {"
|
||||
code.indent do
|
||||
code.line "tag = __external_tags__[tag_var]"
|
||||
code.line "next '' unless tag"
|
||||
code.line "# Create a context using the default environment (which has filters registered)"
|
||||
code.line "ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)"
|
||||
code.line "output = +''"
|
||||
code.line "# Use render_to_output_buffer to ensure block tags work correctly"
|
||||
code.line "tag.render_to_output_buffer(ctx, output)"
|
||||
code.line "output"
|
||||
end
|
||||
code.line "}"
|
||||
end
|
||||
|
||||
# Compile helper for calling external filters at runtime
|
||||
def compile_filter_helper(code)
|
||||
code.line "# Helper for calling external (unknown) filters at runtime"
|
||||
code.line "__call_filter__ = ->(name, input, args) {"
|
||||
code.indent do
|
||||
code.line "if __filter_handler__&.respond_to?(name)"
|
||||
code.indent do
|
||||
code.line "__filter_handler__.send(name, input, *args)"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
code.line "input # Return input unchanged if filter not found"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
code.line "}"
|
||||
end
|
||||
|
||||
# Compile all registered partials as inner methods
|
||||
def compile_partials(code)
|
||||
@partials.each do |name, method_name|
|
||||
source = @partial_sources[name]
|
||||
next unless source
|
||||
|
||||
code.line "# Partial: #{name}"
|
||||
code.line "#{method_name} = ->(partial_assigns) do"
|
||||
code.indent do
|
||||
code.line "__partial_output__ = +''"
|
||||
code.line "# Merge partial assigns with parent assigns"
|
||||
code.line "__assigns_backup__ = assigns.dup"
|
||||
code.line "assigns.merge!(partial_assigns)"
|
||||
code.blank_line
|
||||
|
||||
# Parse and compile the partial
|
||||
# Note: We need to handle this carefully to avoid circular references
|
||||
begin
|
||||
compile_partial_source(source, code)
|
||||
rescue => e
|
||||
code.line "# Error compiling partial: #{e.message.inspect}"
|
||||
code.line "__partial_output__ << '[PARTIAL ERROR: ' + #{name.inspect} + ']'"
|
||||
end
|
||||
|
||||
code.blank_line
|
||||
code.line "# Restore assigns"
|
||||
code.line "assigns.replace(__assigns_backup__)"
|
||||
code.line "__partial_output__"
|
||||
end
|
||||
code.line "end"
|
||||
code.blank_line
|
||||
end
|
||||
end
|
||||
|
||||
# Compile a partial source string
|
||||
def compile_partial_source(source, code)
|
||||
# Parse the partial source using the same environment
|
||||
environment = @template.instance_variable_get(:@environment) || Liquid::Environment.default
|
||||
parse_context = Liquid::ParseContext.new(environment: environment)
|
||||
tokenizer = parse_context.new_tokenizer(source)
|
||||
document = Liquid::Document.parse(tokenizer, parse_context)
|
||||
|
||||
# Compile the partial's body, but swap output variable
|
||||
code.line "__saved_output__ = __output__"
|
||||
code.line "__output__ = __partial_output__"
|
||||
|
||||
# Compile the document
|
||||
BlockBodyCompiler.compile(document.body, self, code)
|
||||
|
||||
code.line "__output__ = __saved_output__"
|
||||
end
|
||||
|
||||
# Generate a unique variable name for internal use
|
||||
def generate_var_name(prefix = "v")
|
||||
@var_counter += 1
|
||||
"__#{prefix}#{@var_counter}__"
|
||||
end
|
||||
|
||||
# Compile a single node
|
||||
def compile_node(node, code)
|
||||
case node
|
||||
when Document
|
||||
BlockBodyCompiler.compile(node.body, self, code)
|
||||
when BlockBody
|
||||
BlockBodyCompiler.compile(node, self, code)
|
||||
when String
|
||||
compile_string(node, code)
|
||||
when Variable
|
||||
emit_debug_comment(code, node, "variable")
|
||||
VariableCompiler.compile(node, self, code)
|
||||
when Tag
|
||||
emit_debug_comment(code, node, node.class.name.split('::').last.downcase)
|
||||
compile_tag(node, code)
|
||||
else
|
||||
raise CompileError, "Unknown node type: #{node.class}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compile_string(str, code)
|
||||
return if str.empty?
|
||||
if debug? && str.length < 40
|
||||
code.line "# LIQUID | text | #{str.inspect}"
|
||||
end
|
||||
code.line "__output__ << #{str.inspect}"
|
||||
end
|
||||
|
||||
def compile_tag(tag, code)
|
||||
compiler_class = find_tag_compiler(tag)
|
||||
if compiler_class
|
||||
compiler_class.compile(tag, self, code)
|
||||
else
|
||||
# Unknown tag - delegate to the original tag's render method at runtime
|
||||
compile_external_tag(tag, code)
|
||||
end
|
||||
end
|
||||
|
||||
def compile_external_tag(tag, code)
|
||||
tag_var = register_external_tag(tag)
|
||||
tag_name = tag.class.name.split('::').last
|
||||
if debug?
|
||||
code.line "# External tag: #{tag_name} (delegated to runtime)"
|
||||
code.line "$stderr.puts '* WARN: Liquid external tag call - #{tag_name} (not compiled, delegated to runtime)' if $VERBOSE"
|
||||
end
|
||||
code.line "__output__ << __call_external_tag__.call(#{tag_var.inspect}, assigns)"
|
||||
end
|
||||
|
||||
def find_tag_compiler(tag)
|
||||
case tag
|
||||
when Liquid::Unless # Check Unless before If since Unless < If
|
||||
Tags::UnlessCompiler
|
||||
when Liquid::If
|
||||
Tags::IfCompiler
|
||||
when Liquid::Case
|
||||
Tags::CaseCompiler
|
||||
when Liquid::For
|
||||
Tags::ForCompiler
|
||||
when Liquid::Assign
|
||||
Tags::AssignCompiler
|
||||
when Liquid::Capture
|
||||
Tags::CaptureCompiler
|
||||
when Liquid::Cycle
|
||||
Tags::CycleCompiler
|
||||
when Liquid::Increment
|
||||
Tags::IncrementCompiler
|
||||
when Liquid::Decrement
|
||||
Tags::DecrementCompiler
|
||||
when Liquid::Raw
|
||||
Tags::RawCompiler
|
||||
when Liquid::Echo
|
||||
Tags::EchoCompiler
|
||||
when Liquid::Break
|
||||
Tags::BreakCompiler
|
||||
when Liquid::Continue
|
||||
Tags::ContinueCompiler
|
||||
when Liquid::Comment, Liquid::InlineComment, Liquid::Doc
|
||||
Tags::CommentCompiler
|
||||
when Liquid::TableRow
|
||||
Tags::TableRowCompiler
|
||||
when Liquid::Render
|
||||
Tags::RenderCompiler
|
||||
when Liquid::Include
|
||||
Tags::IncludeCompiler
|
||||
when Liquid::Ifchanged
|
||||
Tags::IfchangedCompiler
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def compile_helper_methods(code)
|
||||
code.line "# Helper methods for filters and utilities"
|
||||
|
||||
# to_s helper that handles arrays and hashes like Liquid does
|
||||
code.line "def __to_s__(obj)"
|
||||
code.indent do
|
||||
code.line "case obj"
|
||||
code.line "when NilClass then ''"
|
||||
code.line "when Array then obj.join"
|
||||
code.line "else obj.to_s"
|
||||
code.line "end"
|
||||
end
|
||||
code.line "end"
|
||||
code.blank_line
|
||||
|
||||
# to_number helper
|
||||
code.line "def __to_number__(obj)"
|
||||
code.indent do
|
||||
code.line "case obj"
|
||||
code.line "when Numeric then obj"
|
||||
code.line "when String"
|
||||
code.indent do
|
||||
code.line "obj.strip =~ /\\A-?\\d+\\.\\d+\\z/ ? BigDecimal(obj) : obj.to_i"
|
||||
end
|
||||
code.line "else 0"
|
||||
code.line "end"
|
||||
end
|
||||
code.line "end"
|
||||
code.blank_line
|
||||
|
||||
# to_integer helper
|
||||
code.line "def __to_integer__(obj)"
|
||||
code.indent do
|
||||
code.line "return obj if obj.is_a?(Integer)"
|
||||
code.line "Integer(obj.to_s)"
|
||||
end
|
||||
code.line "end"
|
||||
code.blank_line
|
||||
|
||||
# Liquid truthiness helper
|
||||
code.line "def __truthy__(obj)"
|
||||
code.indent do
|
||||
code.line "obj != nil && obj != false"
|
||||
end
|
||||
code.line "end"
|
||||
code.blank_line
|
||||
|
||||
# Variable lookup helper - handles hash/array access, method calls, to_liquid, and drop context
|
||||
code.line "__lookup__ = ->(obj, key) {"
|
||||
code.indent do
|
||||
code.line "return nil if obj.nil?"
|
||||
code.line "# Set context on Drops BEFORE accessing their methods"
|
||||
code.line "obj = obj.to_liquid if obj.respond_to?(:to_liquid)"
|
||||
code.line "obj.context = __context__ if obj.respond_to?(:context=)"
|
||||
code.line "# Now perform the lookup"
|
||||
code.line "result = if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))"
|
||||
code.indent do
|
||||
code.line "obj[key]"
|
||||
end
|
||||
code.line "elsif obj.respond_to?(key)"
|
||||
code.indent do
|
||||
code.line "obj.send(key)"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
code.line "nil"
|
||||
end
|
||||
code.line "end"
|
||||
code.line "# Convert result to liquid and set context for nested Drops"
|
||||
code.line "result = result.to_liquid if result.respond_to?(:to_liquid)"
|
||||
code.line "result.context = __context__ if result.respond_to?(:context=)"
|
||||
code.line "result"
|
||||
end
|
||||
code.line "}"
|
||||
code.blank_line
|
||||
|
||||
# Output helper that handles nil and arrays
|
||||
code.line "def __output_value__(obj)"
|
||||
code.indent do
|
||||
code.line "case obj"
|
||||
code.line "when NilClass then ''"
|
||||
code.line "when Array then obj.map { |o| __output_value__(o) }.join"
|
||||
code.line "else obj.to_s"
|
||||
code.line "end"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
end
|
||||
|
||||
# Custom error for compilation issues
|
||||
class CompileError < StandardError; end
|
||||
end
|
||||
end
|
||||
@@ -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,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% break %} tags
|
||||
#
|
||||
# Breaks out of a for loop
|
||||
class BreakCompiler
|
||||
def self.compile(_tag, _compiler, code)
|
||||
# We use throw/catch in the for loop to handle break
|
||||
# This allows break to work from nested blocks
|
||||
code.line "throw :__loop__break__"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% capture var %}...{% endcapture %} tags
|
||||
#
|
||||
# Captures the output of the block into a variable
|
||||
class CaptureCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
var_name = tag.instance_variable_get(:@to)
|
||||
capture_var = compiler.generate_var_name("capture")
|
||||
|
||||
# Save current output, create new buffer for capture
|
||||
code.line "#{capture_var} = __output__"
|
||||
code.line "__output__ = +''"
|
||||
|
||||
# Compile the body - access the @body BlockBody directly
|
||||
body = tag.instance_variable_get(:@body)
|
||||
if body
|
||||
BlockBodyCompiler.compile(body, compiler, code)
|
||||
end
|
||||
|
||||
# Save captured content and restore output buffer
|
||||
code.line "assigns[#{var_name.inspect}] = __output__"
|
||||
code.line "__output__ = #{capture_var}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% case %} / {% when %} / {% else %} / {% endcase %} tags
|
||||
class CaseCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
# Compile the case expression and store it in a variable
|
||||
# to avoid evaluating it multiple times
|
||||
case_var = compiler.generate_var_name("case")
|
||||
case_expr = ExpressionCompiler.compile(tag.left, compiler)
|
||||
code.line "#{case_var} = #{case_expr}"
|
||||
|
||||
blocks = tag.blocks
|
||||
is_first = true
|
||||
has_else = false
|
||||
|
||||
blocks.each do |block|
|
||||
if block.else?
|
||||
has_else = true
|
||||
code.line "else"
|
||||
else
|
||||
# 'when' condition - compare case expression with the when value
|
||||
when_expr = ExpressionCompiler.compile(block.right, compiler)
|
||||
|
||||
if is_first
|
||||
code.line "if #{case_var} == #{when_expr}"
|
||||
is_first = false
|
||||
else
|
||||
code.line "elsif #{case_var} == #{when_expr}"
|
||||
end
|
||||
end
|
||||
|
||||
code.indent do
|
||||
if block.attachment
|
||||
BlockBodyCompiler.compile(block.attachment, compiler, code)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
code.line "end" unless blocks.empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% comment %}...{% endcomment %} tags
|
||||
# Also handles inline_comment (#) and doc tags
|
||||
#
|
||||
# Comments produce no output
|
||||
class CommentCompiler
|
||||
def self.compile(_tag, _compiler, code)
|
||||
# Comments produce no output
|
||||
code.line "# (liquid comment)"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% continue %} tags
|
||||
#
|
||||
# Skips to the next iteration of a for loop
|
||||
class ContinueCompiler
|
||||
def self.compile(_tag, _compiler, code)
|
||||
# We use throw/catch in the for loop to handle continue
|
||||
code.line "throw :__loop__continue__"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% cycle 'a', 'b', 'c' %} tags
|
||||
#
|
||||
# Cycles through a list of values, outputting the next one each iteration
|
||||
class CycleCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
variables = tag.variables
|
||||
name_expr = ExpressionCompiler.compile(tag.instance_variable_get(:@name), compiler)
|
||||
is_named = tag.named?
|
||||
|
||||
cycle_var = compiler.generate_var_name("cycle")
|
||||
key_var = compiler.generate_var_name("cycle_key")
|
||||
|
||||
# Initialize cycle storage if needed
|
||||
code.line "assigns[:__cycle__] ||= {}"
|
||||
|
||||
# Get the cycle key
|
||||
if is_named
|
||||
code.line "#{key_var} = #{name_expr}"
|
||||
else
|
||||
code.line "#{key_var} = #{variables.object_id}"
|
||||
end
|
||||
|
||||
# Get current index
|
||||
code.line "#{cycle_var} = assigns[:__cycle__][#{key_var}].to_i"
|
||||
|
||||
# Get the value at current index
|
||||
code.line "case #{cycle_var} % #{variables.size}"
|
||||
variables.each_with_index do |var, idx|
|
||||
var_expr = ExpressionCompiler.compile(var, compiler)
|
||||
code.line "when #{idx} then __output__ << __to_s__(#{var_expr})"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Increment counter
|
||||
code.line "assigns[:__cycle__][#{key_var}] = #{cycle_var} + 1"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% decrement var %} tags
|
||||
#
|
||||
# Decrements a counter and outputs its value.
|
||||
# Uses a separate namespace from regular assigns (shares with increment).
|
||||
class DecrementCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
var_name = tag.variable_name
|
||||
|
||||
# Initialize counter storage if needed
|
||||
code.line "assigns[:__counters__] ||= {}"
|
||||
|
||||
# Get current value (default 0), decrement it, output, then store
|
||||
dec_var = compiler.generate_var_name("dec")
|
||||
code.line "#{dec_var} = (assigns[:__counters__][#{var_name.inspect}] || 0) - 1"
|
||||
code.line "assigns[:__counters__][#{var_name.inspect}] = #{dec_var}"
|
||||
code.line "__output__ << #{dec_var}.to_s"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,127 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% for %} / {% else %} / {% endfor %} tags
|
||||
#
|
||||
# Supports:
|
||||
# - Iteration: {% for item in collection %}
|
||||
# - Limit/Offset: {% for item in collection limit:3 offset:2 %}
|
||||
# - Reversed: {% for item in collection reversed %}
|
||||
# - Forloop object: forloop.index, forloop.first, forloop.last, etc.
|
||||
# - Else block: {% for item in collection %}...{% else %}empty{% endfor %}
|
||||
class ForCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
var_name = tag.variable_name
|
||||
collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler)
|
||||
|
||||
# Generate unique variable names for this loop
|
||||
coll_var = compiler.generate_var_name("coll")
|
||||
idx_var = compiler.generate_var_name("idx")
|
||||
len_var = compiler.generate_var_name("len")
|
||||
|
||||
# Evaluate the collection
|
||||
code.line "#{coll_var} = #{collection_expr}"
|
||||
code.line "#{coll_var} = #{coll_var}.to_a if #{coll_var}.is_a?(Range)"
|
||||
|
||||
# Handle limit and offset
|
||||
if tag.from || tag.limit
|
||||
compile_slice(tag, coll_var, compiler, code)
|
||||
end
|
||||
|
||||
# Handle reversed
|
||||
if tag.instance_variable_get(:@reversed)
|
||||
code.line "#{coll_var} = #{coll_var}.reverse"
|
||||
end
|
||||
|
||||
# Check if collection is empty for else block
|
||||
for_block = tag.instance_variable_get(:@for_block)
|
||||
else_block = tag.instance_variable_get(:@else_block)
|
||||
|
||||
if else_block
|
||||
code.line "if #{coll_var}.nil? || (#{coll_var}.respond_to?(:empty?) && #{coll_var}.empty?)"
|
||||
code.indent do
|
||||
BlockBodyCompiler.compile(else_block, compiler, code)
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
|
||||
end
|
||||
code.line "end"
|
||||
else
|
||||
compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.compile_slice(tag, coll_var, compiler, code)
|
||||
from_expr = if tag.from == :continue
|
||||
# Continue from previous offset - we'd need register tracking for this
|
||||
# For now, default to 0
|
||||
"0"
|
||||
elsif tag.from
|
||||
ExpressionCompiler.compile(tag.from, compiler)
|
||||
else
|
||||
"0"
|
||||
end
|
||||
|
||||
if tag.limit
|
||||
limit_expr = ExpressionCompiler.compile(tag.limit, compiler)
|
||||
code.line "#{coll_var} = (#{coll_var}.respond_to?(:slice) ? #{coll_var}.slice(__to_integer__(#{from_expr}), __to_integer__(#{limit_expr})) : #{coll_var}) || []"
|
||||
else
|
||||
code.line "#{coll_var} = (#{coll_var}.respond_to?(:drop) ? #{coll_var}.drop(__to_integer__(#{from_expr})) : #{coll_var}) || []"
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
|
||||
# Calculate length for forloop
|
||||
code.line "#{len_var} = #{coll_var}.respond_to?(:length) ? #{coll_var}.length : 0"
|
||||
code.line "#{idx_var} = 0"
|
||||
|
||||
# The loop itself - use catch/throw for break support across nested blocks
|
||||
code.line "catch(:__loop__break__) do"
|
||||
code.indent do
|
||||
code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|"
|
||||
code.indent do
|
||||
# Wrap each iteration in a catch for continue support
|
||||
code.line "catch(:__loop__continue__) do"
|
||||
code.indent do
|
||||
# Set the loop variable
|
||||
code.line "assigns[#{var_name.inspect}] = __item__"
|
||||
|
||||
# Build the forloop object as a hash
|
||||
code.line "assigns['forloop'] = {"
|
||||
code.indent do
|
||||
code.line "'name' => #{tag.instance_variable_get(:@name).inspect},"
|
||||
code.line "'length' => #{len_var},"
|
||||
code.line "'index' => #{idx_var} + 1,"
|
||||
code.line "'index0' => #{idx_var},"
|
||||
code.line "'rindex' => #{len_var} - #{idx_var},"
|
||||
code.line "'rindex0' => #{len_var} - #{idx_var} - 1,"
|
||||
code.line "'first' => #{idx_var} == 0,"
|
||||
code.line "'last' => #{idx_var} == #{len_var} - 1,"
|
||||
end
|
||||
code.line "}"
|
||||
|
||||
# Compile the loop body
|
||||
BlockBodyCompiler.compile(for_block, compiler, code)
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Increment index (runs even after continue)
|
||||
code.line "#{idx_var} += 1"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Clean up
|
||||
code.line "assigns.delete(#{var_name.inspect})"
|
||||
code.line "assigns.delete('forloop')"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% include 'partial' %} tags
|
||||
#
|
||||
# Include is deprecated in favor of render, but we support it for compatibility.
|
||||
# Unlike render, include shares the outer scope with the partial.
|
||||
#
|
||||
# For static template names, the partial is loaded and inlined at compile time.
|
||||
# For dynamic template names, a runtime fallback is generated.
|
||||
class IncludeCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
template_name_expr = tag.template_name_expr
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.instance_variable_get(:@alias_name)
|
||||
|
||||
# Check if the template name is a static string
|
||||
if template_name_expr.is_a?(String)
|
||||
compile_static_include(tag, template_name_expr, compiler, code)
|
||||
else
|
||||
compile_dynamic_include(tag, compiler, code)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.compile_static_include(tag, template_name, compiler, code)
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.instance_variable_get(:@alias_name)
|
||||
|
||||
# Try to load the partial at compile time
|
||||
partial_source = compiler.load_partial(template_name)
|
||||
|
||||
if partial_source
|
||||
if compiler.debug?
|
||||
code.line "# Inlined partial #{template_name.inspect} at compile time"
|
||||
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial ' + #{template_name.inspect} + ' at compile time' if $VERBOSE"
|
||||
end
|
||||
# Generate a unique method name for this partial
|
||||
method_name = compiler.register_partial(template_name, partial_source)
|
||||
context_var_name = alias_name || template_name.split('/').last
|
||||
|
||||
# Include shares scope, so we just set variables directly
|
||||
code.line "# Include: #{template_name}"
|
||||
|
||||
# Set attributes
|
||||
attributes.each do |key, value|
|
||||
value_expr = ExpressionCompiler.compile(value, compiler)
|
||||
code.line "assigns[#{key.inspect}] = #{value_expr}"
|
||||
end
|
||||
|
||||
# Set the context variable
|
||||
if variable_name_expr
|
||||
var_expr = ExpressionCompiler.compile(variable_name_expr, compiler)
|
||||
var_var = compiler.generate_var_name("incvar")
|
||||
code.line "#{var_var} = #{var_expr}"
|
||||
|
||||
# If it's an array, loop through it
|
||||
code.line "if #{var_var}.is_a?(Array)"
|
||||
code.indent do
|
||||
code.line "#{var_var}.each do |__include_item__|"
|
||||
code.indent do
|
||||
code.line "assigns[#{context_var_name.inspect}] = __include_item__"
|
||||
code.line "__output__ << #{method_name}({})"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
code.line "assigns[#{context_var_name.inspect}] = #{var_var}"
|
||||
code.line "__output__ << #{method_name}({})"
|
||||
end
|
||||
code.line "end"
|
||||
else
|
||||
# Try to find a variable with the same name as the template
|
||||
code.line "assigns[#{context_var_name.inspect}] = assigns[#{template_name.inspect}] if assigns.key?(#{template_name.inspect})"
|
||||
code.line "__output__ << #{method_name}({})"
|
||||
end
|
||||
else
|
||||
# Partial not found at compile time
|
||||
code.line "# Partial '#{template_name}' not found at compile time"
|
||||
compile_dynamic_include(tag, compiler, code)
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_dynamic_include(tag, compiler, code)
|
||||
template_name_expr = tag.template_name_expr
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.instance_variable_get(:@alias_name)
|
||||
|
||||
if compiler.debug?
|
||||
code.line "# Dynamic include (template name from variable)"
|
||||
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic include (template name from variable)' if $VERBOSE"
|
||||
end
|
||||
|
||||
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
|
||||
|
||||
# Build attributes hash
|
||||
attrs_var = compiler.generate_var_name("attrs")
|
||||
code.line "#{attrs_var} = {}"
|
||||
attributes.each do |key, value|
|
||||
value_expr = ExpressionCompiler.compile(value, compiler)
|
||||
code.line "#{attrs_var}[#{key.inspect}] = #{value_expr}"
|
||||
end
|
||||
|
||||
var_expr = variable_name_expr ? ExpressionCompiler.compile(variable_name_expr, compiler) : "nil"
|
||||
alias_expr = alias_name ? alias_name.inspect : "nil"
|
||||
|
||||
# Call the runtime dynamic include method
|
||||
code.line "if defined?(__include_dynamic__)"
|
||||
code.indent do
|
||||
code.line "__output__ << __include_dynamic__(#{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, assigns)"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
code.line "raise RuntimeError, 'Dynamic include requires __include_dynamic__ method: ' + #{name_expr}.inspect"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% increment var %} tags
|
||||
#
|
||||
# Outputs the current counter value, then increments it.
|
||||
# Uses a separate namespace from regular assigns (shares with decrement).
|
||||
class IncrementCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
var_name = tag.variable_name
|
||||
|
||||
# Initialize counter storage if needed
|
||||
code.line "assigns[:__counters__] ||= {}"
|
||||
|
||||
# Get current value (default 0), output it, then increment
|
||||
inc_var = compiler.generate_var_name("inc")
|
||||
code.line "#{inc_var} = assigns[:__counters__][#{var_name.inspect}] || 0"
|
||||
code.line "__output__ << #{inc_var}.to_s"
|
||||
code.line "assigns[:__counters__][#{var_name.inspect}] = #{inc_var} + 1"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,185 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% render 'partial' %} tags
|
||||
#
|
||||
# For static template names (string literals), the partial is loaded at
|
||||
# compile time and inlined as a method.
|
||||
#
|
||||
# For dynamic template names (variables), a runtime fallback is generated
|
||||
# that calls __render_dynamic__ which must be provided by the runtime.
|
||||
class RenderCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
template_name_expr = tag.template_name_expr
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.alias_name
|
||||
is_for_loop = tag.for_loop?
|
||||
|
||||
# Check if the template name is a static string
|
||||
if template_name_expr.is_a?(String)
|
||||
compile_static_render(tag, template_name_expr, compiler, code)
|
||||
else
|
||||
compile_dynamic_render(tag, compiler, code)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.compile_static_render(tag, template_name, compiler, code)
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.alias_name
|
||||
is_for_loop = tag.for_loop?
|
||||
|
||||
# Try to load the partial at compile time
|
||||
partial_source = compiler.load_partial(template_name)
|
||||
|
||||
if partial_source
|
||||
if compiler.debug?
|
||||
code.line "# Inlined partial #{template_name.inspect} at compile time"
|
||||
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial ' + #{template_name.inspect} + ' at compile time' if $VERBOSE"
|
||||
end
|
||||
# Generate a unique method name for this partial
|
||||
method_name = compiler.register_partial(template_name, partial_source)
|
||||
context_var_name = alias_name || template_name.split('/').last
|
||||
|
||||
if is_for_loop && variable_name_expr
|
||||
# Render for each item in collection
|
||||
compile_for_loop_render(tag, method_name, context_var_name, compiler, code)
|
||||
else
|
||||
# Single render
|
||||
compile_single_render(tag, method_name, context_var_name, compiler, code)
|
||||
end
|
||||
else
|
||||
# Partial not found at compile time - generate runtime fallback
|
||||
code.line "# Partial '#{template_name}' not found at compile time"
|
||||
compile_dynamic_render(tag, compiler, code)
|
||||
end
|
||||
end
|
||||
|
||||
def self.compile_single_render(tag, method_name, context_var_name, compiler, code)
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
|
||||
# Build the inner assigns hash
|
||||
inner_assigns_var = compiler.generate_var_name("inner")
|
||||
code.line "#{inner_assigns_var} = {}"
|
||||
|
||||
# Copy attributes
|
||||
attributes.each do |key, value|
|
||||
value_expr = ExpressionCompiler.compile(value, compiler)
|
||||
code.line "#{inner_assigns_var}[#{key.inspect}] = #{value_expr}"
|
||||
end
|
||||
|
||||
# Set the context variable if provided
|
||||
if variable_name_expr
|
||||
var_expr = ExpressionCompiler.compile(variable_name_expr, compiler)
|
||||
code.line "#{inner_assigns_var}[#{context_var_name.inspect}] = #{var_expr}"
|
||||
end
|
||||
|
||||
# Call the partial method
|
||||
code.line "__output__ << #{method_name}(#{inner_assigns_var})"
|
||||
end
|
||||
|
||||
def self.compile_for_loop_render(tag, method_name, context_var_name, compiler, code)
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
template_name = tag.template_name_expr
|
||||
|
||||
coll_var = compiler.generate_var_name("coll")
|
||||
coll_expr = ExpressionCompiler.compile(variable_name_expr, compiler)
|
||||
code.line "#{coll_var} = #{coll_expr}"
|
||||
|
||||
code.line "if #{coll_var}.respond_to?(:each) && #{coll_var}.respond_to?(:count)"
|
||||
code.indent do
|
||||
len_var = compiler.generate_var_name("len")
|
||||
idx_var = compiler.generate_var_name("idx")
|
||||
code.line "#{len_var} = #{coll_var}.count"
|
||||
code.line "#{idx_var} = 0"
|
||||
|
||||
code.line "#{coll_var}.each do |__item__|"
|
||||
code.indent do
|
||||
inner_assigns_var = compiler.generate_var_name("inner")
|
||||
code.line "#{inner_assigns_var} = {}"
|
||||
|
||||
# Copy attributes
|
||||
attributes.each do |key, value|
|
||||
value_expr = ExpressionCompiler.compile(value, compiler)
|
||||
code.line "#{inner_assigns_var}[#{key.inspect}] = #{value_expr}"
|
||||
end
|
||||
|
||||
# Set the context variable
|
||||
code.line "#{inner_assigns_var}[#{context_var_name.inspect}] = __item__"
|
||||
|
||||
# Set forloop
|
||||
code.line "#{inner_assigns_var}['forloop'] = {"
|
||||
code.indent do
|
||||
code.line "'name' => #{template_name.inspect},"
|
||||
code.line "'length' => #{len_var},"
|
||||
code.line "'index' => #{idx_var} + 1,"
|
||||
code.line "'index0' => #{idx_var},"
|
||||
code.line "'rindex' => #{len_var} - #{idx_var},"
|
||||
code.line "'rindex0' => #{len_var} - #{idx_var} - 1,"
|
||||
code.line "'first' => #{idx_var} == 0,"
|
||||
code.line "'last' => #{idx_var} == #{len_var} - 1,"
|
||||
end
|
||||
code.line "}"
|
||||
|
||||
# Call the partial method
|
||||
code.line "__output__ << #{method_name}(#{inner_assigns_var})"
|
||||
|
||||
code.line "#{idx_var} += 1"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
# Single render if not a collection
|
||||
compile_single_render(tag, method_name, context_var_name, compiler, code)
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
|
||||
def self.compile_dynamic_render(tag, compiler, code)
|
||||
template_name_expr = tag.template_name_expr
|
||||
variable_name_expr = tag.variable_name_expr
|
||||
attributes = tag.attributes
|
||||
alias_name = tag.alias_name
|
||||
is_for_loop = tag.for_loop?
|
||||
|
||||
if compiler.debug?
|
||||
code.line "# Dynamic render (template name from variable)"
|
||||
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic render (template name from variable)' if $VERBOSE"
|
||||
end
|
||||
|
||||
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
|
||||
|
||||
# Build attributes hash
|
||||
attrs_var = compiler.generate_var_name("attrs")
|
||||
code.line "#{attrs_var} = {}"
|
||||
attributes.each do |key, value|
|
||||
value_expr = ExpressionCompiler.compile(value, compiler)
|
||||
code.line "#{attrs_var}[#{key.inspect}] = #{value_expr}"
|
||||
end
|
||||
|
||||
var_expr = variable_name_expr ? ExpressionCompiler.compile(variable_name_expr, compiler) : "nil"
|
||||
alias_expr = alias_name ? alias_name.inspect : "nil"
|
||||
|
||||
# Call the runtime dynamic render method
|
||||
code.line "if defined?(__render_dynamic__)"
|
||||
code.indent do
|
||||
code.line "__output__ << __render_dynamic__(#{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, #{is_for_loop})"
|
||||
end
|
||||
code.line "else"
|
||||
code.indent do
|
||||
code.line "raise RuntimeError, 'Dynamic render requires __render_dynamic__ method: ' + #{name_expr}.inspect"
|
||||
end
|
||||
code.line "end"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% tablerow %} tags
|
||||
#
|
||||
# Creates HTML table rows from a collection
|
||||
class TableRowCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
var_name = tag.variable_name
|
||||
collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler)
|
||||
attributes = tag.attributes
|
||||
|
||||
# Generate unique variable names
|
||||
coll_var = compiler.generate_var_name("coll")
|
||||
idx_var = compiler.generate_var_name("idx")
|
||||
len_var = compiler.generate_var_name("len")
|
||||
cols_var = compiler.generate_var_name("cols")
|
||||
row_var = compiler.generate_var_name("row")
|
||||
col_var = compiler.generate_var_name("col")
|
||||
|
||||
# Get parameters from attributes hash
|
||||
cols = attributes['cols']
|
||||
cols_expr = cols ? ExpressionCompiler.compile(cols, compiler) : "nil"
|
||||
|
||||
# Evaluate the collection
|
||||
code.line "#{coll_var} = #{collection_expr}"
|
||||
code.line "#{coll_var} = #{coll_var}.to_a if #{coll_var}.is_a?(Range)"
|
||||
|
||||
# Handle limit and offset from attributes
|
||||
offset = attributes['offset']
|
||||
limit = attributes['limit']
|
||||
if offset || limit
|
||||
if offset
|
||||
offset_expr = ExpressionCompiler.compile(offset, compiler)
|
||||
if limit
|
||||
limit_expr = ExpressionCompiler.compile(limit, compiler)
|
||||
code.line "#{coll_var} = #{coll_var}.slice(__to_integer__(#{offset_expr}), __to_integer__(#{limit_expr})) || []"
|
||||
else
|
||||
code.line "#{coll_var} = #{coll_var}.drop(__to_integer__(#{offset_expr}))"
|
||||
end
|
||||
elsif limit
|
||||
limit_expr = ExpressionCompiler.compile(limit, compiler)
|
||||
code.line "#{coll_var} = #{coll_var}.first(__to_integer__(#{limit_expr}))"
|
||||
end
|
||||
end
|
||||
|
||||
# Setup loop variables
|
||||
code.line "#{len_var} = #{coll_var}.respond_to?(:length) ? #{coll_var}.length : 0"
|
||||
code.line "#{cols_var} = #{cols_expr} || #{len_var}"
|
||||
code.line "#{idx_var} = 0"
|
||||
code.line "#{row_var} = 1"
|
||||
code.line "#{col_var} = 0"
|
||||
|
||||
body = tag.instance_variable_get(:@body)
|
||||
|
||||
# Output initial row (matches interpreter behavior: outputs before loop)
|
||||
code.line "__output__ << \"<tr class=\\\"row1\\\">\\n\""
|
||||
|
||||
# The loop
|
||||
code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|"
|
||||
code.indent do
|
||||
code.line "#{col_var} += 1"
|
||||
|
||||
# Output cell start
|
||||
code.line "__output__ << \"<td class=\\\"col\#{#{col_var}}\\\">\""
|
||||
|
||||
# Set loop variables
|
||||
code.line "assigns[#{var_name.inspect}] = __item__"
|
||||
code.line "assigns['tablerowloop'] = {"
|
||||
code.indent do
|
||||
code.line "'length' => #{len_var},"
|
||||
code.line "'index' => #{idx_var} + 1,"
|
||||
code.line "'index0' => #{idx_var},"
|
||||
code.line "'rindex' => #{len_var} - #{idx_var},"
|
||||
code.line "'rindex0' => #{len_var} - #{idx_var} - 1,"
|
||||
code.line "'first' => #{idx_var} == 0,"
|
||||
code.line "'last' => #{idx_var} == #{len_var} - 1,"
|
||||
code.line "'col' => #{col_var},"
|
||||
code.line "'col0' => #{col_var} - 1,"
|
||||
code.line "'row' => #{row_var},"
|
||||
code.line "'col_first' => #{col_var} == 1,"
|
||||
code.line "'col_last' => #{col_var} == #{cols_var},"
|
||||
end
|
||||
code.line "}"
|
||||
|
||||
# Compile the body
|
||||
BlockBodyCompiler.compile(body, compiler, code)
|
||||
|
||||
# Output cell end
|
||||
code.line "__output__ << '</td>'"
|
||||
|
||||
# End row and start new row if needed (but not on last item)
|
||||
code.line "if #{col_var} == #{cols_var} && #{idx_var} != #{len_var} - 1"
|
||||
code.indent do
|
||||
code.line "__output__ << \"</tr>\\n<tr class=\\\"row\#{#{row_var} + 1}\\\">\""
|
||||
code.line "#{col_var} = 0"
|
||||
code.line "#{row_var} += 1"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
code.line "#{idx_var} += 1"
|
||||
end
|
||||
code.line "end"
|
||||
|
||||
# Close the final row
|
||||
code.line "__output__ << \"</tr>\\n\""
|
||||
|
||||
# Clean up
|
||||
code.line "assigns.delete(#{var_name.inspect})"
|
||||
code.line "assigns.delete('tablerowloop')"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
module Tags
|
||||
# Compiles {% unless %} / {% elsif %} / {% else %} / {% endunless %} tags
|
||||
#
|
||||
# Unless is like if, but the first condition is negated
|
||||
class UnlessCompiler
|
||||
def self.compile(tag, compiler, code)
|
||||
blocks = tag.blocks
|
||||
|
||||
blocks.each_with_index do |block, index|
|
||||
condition_expr = ConditionCompiler.compile(block, compiler)
|
||||
|
||||
if index == 0
|
||||
# First block is negated (unless = if not)
|
||||
code.line "unless #{condition_expr}"
|
||||
elsif block.else?
|
||||
code.line "else"
|
||||
else
|
||||
# Subsequent blocks (elsif) are normal
|
||||
code.line "elsif #{condition_expr}"
|
||||
end
|
||||
|
||||
code.indent do
|
||||
if block.attachment
|
||||
BlockBodyCompiler.compile(block.attachment, compiler, code)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
code.line "end"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
module Compile
|
||||
# VariableCompiler compiles Liquid variable expressions ({{ ... }}) to Ruby code.
|
||||
#
|
||||
# A Variable consists of:
|
||||
# - A name expression (the value to output)
|
||||
# - Zero or more filters to apply
|
||||
class VariableCompiler
|
||||
# Compile a Variable node and append the result to the output buffer
|
||||
# @param variable [Liquid::Variable] The variable node
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @param code [CodeGenerator] The code generator
|
||||
def self.compile(variable, compiler, code)
|
||||
value_expr = compile_to_expression(variable, compiler)
|
||||
code.line "__output__ << __output_value__(#{value_expr})"
|
||||
end
|
||||
|
||||
# Compile a Variable node to a Ruby expression (without output)
|
||||
# @param variable [Liquid::Variable] The variable node
|
||||
# @param compiler [RubyCompiler] The main compiler instance
|
||||
# @return [String] Ruby code expression
|
||||
def self.compile_to_expression(variable, compiler)
|
||||
# Compile the base name expression
|
||||
base_expr = ExpressionCompiler.compile(variable.name, compiler)
|
||||
|
||||
# Apply filters if any
|
||||
if variable.filters && !variable.filters.empty?
|
||||
FilterCompiler.compile(base_expr, variable.filters, compiler)
|
||||
else
|
||||
base_expr
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -40,6 +40,10 @@ module Liquid
|
||||
@global_filter = nil
|
||||
@disabled_tags = {}
|
||||
|
||||
# Instead of constructing new StringScanner objects for each Expression parse,
|
||||
# we recycle the same one.
|
||||
@string_scanner = StringScanner.new("")
|
||||
|
||||
@registers.static[:cached_partials] ||= {}
|
||||
@registers.static[:file_system] ||= environment.file_system
|
||||
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new
|
||||
@@ -176,7 +180,7 @@ module Liquid
|
||||
# Example:
|
||||
# products == empty #=> products.empty?
|
||||
def [](expression)
|
||||
evaluate(Expression.parse(expression))
|
||||
evaluate(Expression.parse(expression, @string_scanner))
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
|
||||
+97
-21
@@ -10,37 +10,113 @@ module Liquid
|
||||
'true' => true,
|
||||
'false' => false,
|
||||
'blank' => '',
|
||||
'empty' => ''
|
||||
'empty' => '',
|
||||
# in lax mode, minus sign can be a VariableLookup
|
||||
# For simplicity and performace, we treat it like a literal
|
||||
'-' => VariableLookup.parse("-", nil).freeze,
|
||||
}.freeze
|
||||
|
||||
INTEGERS_REGEX = /\A(-?\d+)\z/
|
||||
FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
|
||||
DOT = ".".ord
|
||||
ZERO = "0".ord
|
||||
NINE = "9".ord
|
||||
DASH = "-".ord
|
||||
|
||||
# Use an atomic group (?>...) to avoid pathological backtracing from
|
||||
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
|
||||
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
|
||||
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
|
||||
INTEGER_REGEX = /\A(-?\d+)\z/
|
||||
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
|
||||
|
||||
def self.parse(markup)
|
||||
return nil unless markup
|
||||
class << self
|
||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||
return unless markup
|
||||
|
||||
markup = markup.strip
|
||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||
(markup.start_with?("'") && markup.end_with?("'"))
|
||||
return markup[1..-2]
|
||||
markup = markup.strip # markup can be a frozen string
|
||||
|
||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||
(markup.start_with?("'") && markup.end_with?("'"))
|
||||
return markup[1..-2]
|
||||
elsif LITERALS.key?(markup)
|
||||
return LITERALS[markup]
|
||||
end
|
||||
|
||||
# Cache only exists during parsing
|
||||
if cache
|
||||
return cache[markup] if cache.key?(markup)
|
||||
|
||||
cache[markup] = inner_parse(markup, ss, cache).freeze
|
||||
else
|
||||
inner_parse(markup, ss, nil).freeze
|
||||
end
|
||||
end
|
||||
|
||||
case markup
|
||||
when INTEGERS_REGEX
|
||||
Regexp.last_match(1).to_i
|
||||
when RANGES_REGEX
|
||||
RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2))
|
||||
when FLOATS_REGEX
|
||||
Regexp.last_match(1).to_f
|
||||
else
|
||||
if LITERALS.key?(markup)
|
||||
LITERALS[markup]
|
||||
def inner_parse(markup, ss, cache)
|
||||
if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
|
||||
return RangeLookup.parse(
|
||||
Regexp.last_match(1),
|
||||
Regexp.last_match(2),
|
||||
ss,
|
||||
cache,
|
||||
)
|
||||
end
|
||||
|
||||
if (num = parse_number(markup, ss))
|
||||
num
|
||||
else
|
||||
VariableLookup.parse(markup)
|
||||
VariableLookup.parse(markup, ss, cache)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_number(markup, ss)
|
||||
# check if the markup is simple integer or float
|
||||
case markup
|
||||
when INTEGER_REGEX
|
||||
return Integer(markup, 10)
|
||||
when FLOAT_REGEX
|
||||
return markup.to_f
|
||||
end
|
||||
|
||||
ss.string = markup
|
||||
# the first byte must be a digit or a dash
|
||||
byte = ss.scan_byte
|
||||
|
||||
return false if byte != DASH && (byte < ZERO || byte > NINE)
|
||||
|
||||
if byte == DASH
|
||||
peek_byte = ss.peek_byte
|
||||
|
||||
# if it starts with a dash, the next byte must be a digit
|
||||
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
|
||||
end
|
||||
|
||||
# The markup could be a float with multiple dots
|
||||
first_dot_pos = nil
|
||||
num_end_pos = nil
|
||||
|
||||
while (byte = ss.scan_byte)
|
||||
return false if byte != DOT && (byte < ZERO || byte > NINE)
|
||||
|
||||
# we found our number and now we are just scanning the rest of the string
|
||||
next if num_end_pos
|
||||
|
||||
if byte == DOT
|
||||
if first_dot_pos.nil?
|
||||
first_dot_pos = ss.pos
|
||||
else
|
||||
# we found another dot, so we know that the number ends here
|
||||
num_end_pos = ss.pos - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
num_end_pos = markup.length if ss.eos?
|
||||
|
||||
if num_end_pos
|
||||
# number ends with a number "123.123"
|
||||
markup.byteslice(0, num_end_pos).to_f
|
||||
else
|
||||
# number ends with a dot "123."
|
||||
markup.byteslice(0, first_dot_pos).to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+64
-122
@@ -1,66 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "strscan"
|
||||
|
||||
module Liquid
|
||||
class Lexer1
|
||||
SPECIALS = {
|
||||
'|' => :pipe,
|
||||
'.' => :dot,
|
||||
':' => :colon,
|
||||
',' => :comma,
|
||||
'[' => :open_square,
|
||||
']' => :close_square,
|
||||
'(' => :open_round,
|
||||
')' => :close_round,
|
||||
'?' => :question,
|
||||
'-' => :dash,
|
||||
}.freeze
|
||||
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
|
||||
SINGLE_STRING_LITERAL = /'[^\']*'/
|
||||
DOUBLE_STRING_LITERAL = /"[^\"]*"/
|
||||
STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
|
||||
NUMBER_LITERAL = /-?\d+(\.\d+)?/
|
||||
DOTDOT = /\.\./
|
||||
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
|
||||
WHITESPACE_OR_NOTHING = /\s*/
|
||||
|
||||
def initialize(input)
|
||||
@ss = StringScanner.new(input)
|
||||
end
|
||||
|
||||
def tokenize
|
||||
@output = []
|
||||
|
||||
until @ss.eos?
|
||||
@ss.skip(WHITESPACE_OR_NOTHING)
|
||||
break if @ss.eos?
|
||||
tok = if (t = @ss.scan(COMPARISON_OPERATOR))
|
||||
[:comparison, t]
|
||||
elsif (t = @ss.scan(STRING_LITERAL))
|
||||
[:string, t]
|
||||
elsif (t = @ss.scan(NUMBER_LITERAL))
|
||||
[:number, t]
|
||||
elsif (t = @ss.scan(IDENTIFIER))
|
||||
[:id, t]
|
||||
elsif (t = @ss.scan(DOTDOT))
|
||||
[:dotdot, t]
|
||||
else
|
||||
c = @ss.getch
|
||||
if (s = SPECIALS[c])
|
||||
[s, c]
|
||||
else
|
||||
raise SyntaxError, "Unexpected character #{c}"
|
||||
end
|
||||
end
|
||||
@output << tok
|
||||
end
|
||||
|
||||
@output << [:end_of_string]
|
||||
end
|
||||
end
|
||||
|
||||
class Lexer2
|
||||
class Lexer
|
||||
CLOSE_ROUND = [:close_round, ")"].freeze
|
||||
CLOSE_SQUARE = [:close_square, "]"].freeze
|
||||
COLON = [:colon, ":"].freeze
|
||||
@@ -92,6 +33,7 @@ module Liquid
|
||||
SINGLE_COMPARISON_TOKENS = [].tap do |table|
|
||||
table["<".ord] = COMPARISON_LESS_THAN
|
||||
table[">".ord] = COMPARISON_GREATER_THAN
|
||||
table.freeze
|
||||
end
|
||||
|
||||
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
|
||||
@@ -103,18 +45,17 @@ module Liquid
|
||||
sub_table["=".ord] = COMPARISION_NOT_EQUAL
|
||||
sub_table.freeze
|
||||
end
|
||||
table.freeze
|
||||
end
|
||||
|
||||
COMPARISON_JUMP_TABLE = [].tap do |table|
|
||||
table["<".ord] = [].tap do |sub_table|
|
||||
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
|
||||
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
|
||||
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN }
|
||||
sub_table.freeze
|
||||
end
|
||||
table[">".ord] = [].tap do |sub_table|
|
||||
sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
|
||||
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN }
|
||||
sub_table.freeze
|
||||
end
|
||||
table.freeze
|
||||
@@ -157,81 +98,82 @@ module Liquid
|
||||
table.freeze
|
||||
end
|
||||
|
||||
def initialize(input)
|
||||
@ss = StringScanner.new(input)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/BlockNesting
|
||||
def tokenize
|
||||
@output = []
|
||||
class << self
|
||||
def tokenize(ss)
|
||||
output = []
|
||||
|
||||
until @ss.eos?
|
||||
@ss.skip(WHITESPACE_OR_NOTHING)
|
||||
until ss.eos?
|
||||
ss.skip(WHITESPACE_OR_NOTHING)
|
||||
|
||||
break if @ss.eos?
|
||||
break if ss.eos?
|
||||
|
||||
start_pos = @ss.pos
|
||||
peeked = @ss.peek_byte
|
||||
start_pos = ss.pos
|
||||
peeked = ss.peek_byte
|
||||
|
||||
if (special = SPECIAL_TABLE[peeked])
|
||||
@ss.scan_byte
|
||||
# Special case for ".."
|
||||
if special == DOT && @ss.peek_byte == DOT_ORD
|
||||
@ss.scan_byte
|
||||
@output << DOTDOT
|
||||
elsif special == DASH
|
||||
# Special case for negative numbers
|
||||
if (peeked_byte = @ss.peek_byte) && NUMBER_TABLE[peeked_byte]
|
||||
@ss.pos -= 1
|
||||
@output << [:number, @ss.scan(NUMBER_LITERAL)]
|
||||
if (special = SPECIAL_TABLE[peeked])
|
||||
ss.scan_byte
|
||||
# Special case for ".."
|
||||
if special == DOT && ss.peek_byte == DOT_ORD
|
||||
ss.scan_byte
|
||||
output << DOTDOT
|
||||
elsif special == DASH
|
||||
# Special case for negative numbers
|
||||
if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte]
|
||||
ss.pos -= 1
|
||||
output << [:number, ss.scan(NUMBER_LITERAL)]
|
||||
else
|
||||
output << special
|
||||
end
|
||||
else
|
||||
@output << special
|
||||
output << special
|
||||
end
|
||||
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
|
||||
ss.scan_byte
|
||||
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||
output << found
|
||||
ss.scan_byte
|
||||
else
|
||||
raise_syntax_error(start_pos, ss)
|
||||
end
|
||||
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
|
||||
ss.scan_byte
|
||||
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||
output << found
|
||||
ss.scan_byte
|
||||
else
|
||||
output << SINGLE_COMPARISON_TOKENS[peeked]
|
||||
end
|
||||
else
|
||||
@output << special
|
||||
end
|
||||
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
|
||||
@ss.scan_byte
|
||||
if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||
@output << found
|
||||
@ss.scan_byte
|
||||
else
|
||||
raise_syntax_error(start_pos)
|
||||
end
|
||||
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
|
||||
@ss.scan_byte
|
||||
if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||
@output << found
|
||||
@ss.scan_byte
|
||||
else
|
||||
@output << SINGLE_COMPARISON_TOKENS[peeked]
|
||||
end
|
||||
else
|
||||
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
|
||||
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
|
||||
|
||||
if type && (t = @ss.scan(pattern))
|
||||
# Special case for "contains"
|
||||
@output << if type == :id && t == "contains" && @output.last&.first != :dot
|
||||
COMPARISON_CONTAINS
|
||||
if type && (t = ss.scan(pattern))
|
||||
# Special case for "contains"
|
||||
output << if type == :id && t == "contains" && output.last&.first != :dot
|
||||
COMPARISON_CONTAINS
|
||||
else
|
||||
[type, t]
|
||||
end
|
||||
else
|
||||
[type, t]
|
||||
raise_syntax_error(start_pos, ss)
|
||||
end
|
||||
else
|
||||
raise_syntax_error(start_pos)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
output << EOS
|
||||
rescue ::ArgumentError => e
|
||||
if e.message == "invalid byte sequence in #{ss.string.encoding}"
|
||||
raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
|
||||
@output << EOS
|
||||
end
|
||||
|
||||
def raise_syntax_error(start_pos)
|
||||
@ss.pos = start_pos
|
||||
# the character could be a UTF-8 character, use getch to get all the bytes
|
||||
raise SyntaxError, "Unexpected character #{@ss.getch}"
|
||||
def raise_syntax_error(start_pos, ss)
|
||||
ss.pos = start_pos
|
||||
# the character could be a UTF-8 character, use getch to get all the bytes
|
||||
raise SyntaxError, "Unexpected character #{ss.getch}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1
|
||||
end
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
errors:
|
||||
syntax:
|
||||
tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}"
|
||||
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]"
|
||||
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) "
|
||||
cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"
|
||||
doc_invalid_nested: "Syntax Error in 'doc' - Nested doc tags are not allowed"
|
||||
for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"
|
||||
for_invalid_in: "For loops require an 'in' clause"
|
||||
for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset"
|
||||
|
||||
@@ -12,6 +12,18 @@ module Liquid
|
||||
@locale = @template_options[:locale] ||= I18n.new
|
||||
@warnings = []
|
||||
|
||||
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
|
||||
# This StringScanner will be shared by all of them
|
||||
@string_scanner = StringScanner.new("")
|
||||
|
||||
@expression_cache = if options[:expression_cache].nil?
|
||||
{}
|
||||
elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=)
|
||||
options[:expression_cache]
|
||||
elsif options[:expression_cache]
|
||||
{}
|
||||
end
|
||||
|
||||
self.depth = 0
|
||||
self.partial = false
|
||||
end
|
||||
@@ -24,12 +36,22 @@ module Liquid
|
||||
Liquid::BlockBody.new
|
||||
end
|
||||
|
||||
def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false)
|
||||
Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag)
|
||||
def new_parser(input)
|
||||
@string_scanner.string = input
|
||||
Parser.new(@string_scanner)
|
||||
end
|
||||
|
||||
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
|
||||
Tokenizer.new(
|
||||
source: source,
|
||||
string_scanner: @string_scanner,
|
||||
line_number: start_line_number,
|
||||
for_liquid_tag: for_liquid_tag,
|
||||
)
|
||||
end
|
||||
|
||||
def parse_expression(markup)
|
||||
Expression.parse(markup)
|
||||
Expression.parse(markup, @string_scanner, @expression_cache)
|
||||
end
|
||||
|
||||
def partial=(value)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
module Liquid
|
||||
class Parser
|
||||
def initialize(input)
|
||||
l = Lexer.new(input)
|
||||
@tokens = l.tokenize
|
||||
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
|
||||
@tokens = Lexer.tokenize(ss)
|
||||
@p = 0 # pointer to current location
|
||||
end
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
module Liquid
|
||||
class RangeLookup
|
||||
def self.parse(start_markup, end_markup)
|
||||
start_obj = Expression.parse(start_markup)
|
||||
end_obj = Expression.parse(end_markup)
|
||||
def self.parse(start_markup, end_markup, string_scanner, cache = nil)
|
||||
start_obj = Expression.parse(start_markup, string_scanner, cache)
|
||||
end_obj = Expression.parse(end_markup, string_scanner, cache)
|
||||
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
|
||||
new(start_obj, end_obj)
|
||||
else
|
||||
|
||||
+153
-61
@@ -3,7 +3,6 @@
|
||||
require 'cgi'
|
||||
require 'base64'
|
||||
require 'bigdecimal'
|
||||
|
||||
module Liquid
|
||||
module StandardFilters
|
||||
MAX_I32 = (1 << 31) - 1
|
||||
@@ -64,7 +63,7 @@ module Liquid
|
||||
# @liquid_syntax string | downcase
|
||||
# @liquid_return [string]
|
||||
def downcase(input)
|
||||
input.to_s.downcase
|
||||
Utils.to_s(input).downcase
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -75,7 +74,7 @@ module Liquid
|
||||
# @liquid_syntax string | upcase
|
||||
# @liquid_return [string]
|
||||
def upcase(input)
|
||||
input.to_s.upcase
|
||||
Utils.to_s(input).upcase
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -86,7 +85,7 @@ module Liquid
|
||||
# @liquid_syntax string | capitalize
|
||||
# @liquid_return [string]
|
||||
def capitalize(input)
|
||||
input.to_s.capitalize
|
||||
Utils.to_s(input).capitalize
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -97,7 +96,7 @@ module Liquid
|
||||
# @liquid_syntax string | escape
|
||||
# @liquid_return [string]
|
||||
def escape(input)
|
||||
CGI.escapeHTML(input.to_s) unless input.nil?
|
||||
CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
|
||||
end
|
||||
alias_method :h, :escape
|
||||
|
||||
@@ -109,7 +108,7 @@ module Liquid
|
||||
# @liquid_syntax string | escape_once
|
||||
# @liquid_return [string]
|
||||
def escape_once(input)
|
||||
input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
|
||||
Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -124,7 +123,7 @@ module Liquid
|
||||
# @liquid_syntax string | url_encode
|
||||
# @liquid_return [string]
|
||||
def url_encode(input)
|
||||
CGI.escape(input.to_s) unless input.nil?
|
||||
CGI.escape(Utils.to_s(input)) unless input.nil?
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -138,7 +137,7 @@ module Liquid
|
||||
def url_decode(input)
|
||||
return if input.nil?
|
||||
|
||||
result = CGI.unescape(input.to_s)
|
||||
result = CGI.unescape(Utils.to_s(input))
|
||||
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
|
||||
|
||||
result
|
||||
@@ -152,7 +151,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_encode
|
||||
# @liquid_return [string]
|
||||
def base64_encode(input)
|
||||
Base64.strict_encode64(input.to_s)
|
||||
Base64.strict_encode64(Utils.to_s(input))
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -163,7 +162,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_decode
|
||||
# @liquid_return [string]
|
||||
def base64_decode(input)
|
||||
input = input.to_s
|
||||
input = Utils.to_s(input)
|
||||
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
|
||||
rescue ::ArgumentError
|
||||
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
|
||||
@@ -177,7 +176,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_url_safe_encode
|
||||
# @liquid_return [string]
|
||||
def base64_url_safe_encode(input)
|
||||
Base64.urlsafe_encode64(input.to_s)
|
||||
Base64.urlsafe_encode64(Utils.to_s(input))
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -188,7 +187,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_url_safe_decode
|
||||
# @liquid_return [string]
|
||||
def base64_url_safe_decode(input)
|
||||
input = input.to_s
|
||||
input = Utils.to_s(input)
|
||||
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
|
||||
rescue ::ArgumentError
|
||||
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
|
||||
@@ -212,7 +211,7 @@ module Liquid
|
||||
if input.is_a?(Array)
|
||||
input.slice(offset, length) || []
|
||||
else
|
||||
input.to_s.slice(offset, length) || ''
|
||||
Utils.to_s(input).slice(offset, length) || ''
|
||||
end
|
||||
rescue RangeError
|
||||
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
|
||||
@@ -236,10 +235,10 @@ module Liquid
|
||||
# @liquid_return [string]
|
||||
def truncate(input, length = 50, truncate_string = "...")
|
||||
return if input.nil?
|
||||
input_str = input.to_s
|
||||
input_str = Utils.to_s(input)
|
||||
length = Utils.to_integer(length)
|
||||
|
||||
truncate_string_str = truncate_string.to_s
|
||||
truncate_string_str = Utils.to_s(truncate_string)
|
||||
|
||||
l = length - truncate_string_str.length
|
||||
l = 0 if l < 0
|
||||
@@ -263,7 +262,7 @@ module Liquid
|
||||
# @liquid_return [string]
|
||||
def truncatewords(input, words = 15, truncate_string = "...")
|
||||
return if input.nil?
|
||||
input = input.to_s
|
||||
input = Utils.to_s(input)
|
||||
words = Utils.to_integer(words)
|
||||
words = 1 if words <= 0
|
||||
|
||||
@@ -277,7 +276,8 @@ module Liquid
|
||||
return input if wordlist.length <= words
|
||||
|
||||
wordlist.pop
|
||||
wordlist.join(" ").concat(truncate_string.to_s)
|
||||
truncate_string = Utils.to_s(truncate_string)
|
||||
wordlist.join(" ").concat(truncate_string)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -288,7 +288,9 @@ module Liquid
|
||||
# @liquid_syntax string | split: string
|
||||
# @liquid_return [array[string]]
|
||||
def split(input, pattern)
|
||||
input.to_s.split(pattern.to_s)
|
||||
pattern = Utils.to_s(pattern)
|
||||
input = Utils.to_s(input)
|
||||
input.split(pattern)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -299,7 +301,8 @@ module Liquid
|
||||
# @liquid_syntax string | strip
|
||||
# @liquid_return [string]
|
||||
def strip(input)
|
||||
input.to_s.strip
|
||||
input = Utils.to_s(input)
|
||||
input.strip
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -310,7 +313,8 @@ module Liquid
|
||||
# @liquid_syntax string | lstrip
|
||||
# @liquid_return [string]
|
||||
def lstrip(input)
|
||||
input.to_s.lstrip
|
||||
input = Utils.to_s(input)
|
||||
input.lstrip
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -321,7 +325,8 @@ module Liquid
|
||||
# @liquid_syntax string | rstrip
|
||||
# @liquid_return [string]
|
||||
def rstrip(input)
|
||||
input.to_s.rstrip
|
||||
input = Utils.to_s(input)
|
||||
input.rstrip
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -332,8 +337,9 @@ module Liquid
|
||||
# @liquid_syntax string | strip_html
|
||||
# @liquid_return [string]
|
||||
def strip_html(input)
|
||||
input = Utils.to_s(input)
|
||||
empty = ''
|
||||
result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
|
||||
result = input.gsub(STRIP_HTML_BLOCKS, empty)
|
||||
result.gsub!(STRIP_HTML_TAGS, empty)
|
||||
result
|
||||
end
|
||||
@@ -346,7 +352,8 @@ module Liquid
|
||||
# @liquid_syntax string | strip_newlines
|
||||
# @liquid_return [string]
|
||||
def strip_newlines(input)
|
||||
input.to_s.gsub(/\r?\n/, '')
|
||||
input = Utils.to_s(input)
|
||||
input.gsub(/\r?\n/, '')
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -357,6 +364,7 @@ module Liquid
|
||||
# @liquid_syntax array | join
|
||||
# @liquid_return [string]
|
||||
def join(input, glue = ' ')
|
||||
glue = Utils.to_s(glue)
|
||||
InputIterator.new(input, context).join(glue)
|
||||
end
|
||||
|
||||
@@ -424,29 +432,59 @@ module Liquid
|
||||
# @liquid_syntax array | where: string, string
|
||||
# @liquid_return [array[untyped]]
|
||||
def where(input, property, target_value = nil)
|
||||
ary = InputIterator.new(input, context)
|
||||
filter_array(input, property, target_value) { |ary, &block| ary.select(&block) }
|
||||
end
|
||||
|
||||
if ary.empty?
|
||||
[]
|
||||
elsif target_value.nil?
|
||||
ary.select do |item|
|
||||
item[property]
|
||||
rescue TypeError
|
||||
raise_property_error(property)
|
||||
rescue NoMethodError
|
||||
return nil unless item.respond_to?(:[])
|
||||
raise
|
||||
end
|
||||
else
|
||||
ary.select do |item|
|
||||
item[property] == target_value
|
||||
rescue TypeError
|
||||
raise_property_error(property)
|
||||
rescue NoMethodError
|
||||
return nil unless item.respond_to?(:[])
|
||||
raise
|
||||
end
|
||||
end
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Filters an array to exclude items with a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | reject: string, string
|
||||
# @liquid_return [array[untyped]]
|
||||
def reject(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Tests if any item in an array has a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | has: string, string
|
||||
# @liquid_return [boolean]
|
||||
def has(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Returns the first item in an array with a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | find: string, string
|
||||
# @liquid_return [untyped]
|
||||
def find(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Returns the index of the first item in an array with a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | find_index: string, string
|
||||
# @liquid_return [number]
|
||||
def find_index(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -543,7 +581,10 @@ module Liquid
|
||||
# @liquid_syntax string | replace: string, string
|
||||
# @liquid_return [string]
|
||||
def replace(input, string, replacement = '')
|
||||
input.to_s.gsub(string.to_s, replacement.to_s)
|
||||
string = Utils.to_s(string)
|
||||
replacement = Utils.to_s(replacement)
|
||||
input = Utils.to_s(input)
|
||||
input.gsub(string, replacement)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -554,7 +595,10 @@ module Liquid
|
||||
# @liquid_syntax string | replace_first: string, string
|
||||
# @liquid_return [string]
|
||||
def replace_first(input, string, replacement = '')
|
||||
input.to_s.sub(string.to_s, replacement.to_s)
|
||||
string = Utils.to_s(string)
|
||||
replacement = Utils.to_s(replacement)
|
||||
input = Utils.to_s(input)
|
||||
input.sub(string, replacement)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -565,9 +609,9 @@ module Liquid
|
||||
# @liquid_syntax string | replace_last: string, string
|
||||
# @liquid_return [string]
|
||||
def replace_last(input, string, replacement)
|
||||
input = input.to_s
|
||||
string = string.to_s
|
||||
replacement = replacement.to_s
|
||||
input = Utils.to_s(input)
|
||||
string = Utils.to_s(string)
|
||||
replacement = Utils.to_s(replacement)
|
||||
|
||||
start_index = input.rindex(string)
|
||||
|
||||
@@ -619,7 +663,9 @@ module Liquid
|
||||
# @liquid_syntax string | append: string
|
||||
# @liquid_return [string]
|
||||
def append(input, string)
|
||||
input.to_s + string.to_s
|
||||
input = Utils.to_s(input)
|
||||
string = Utils.to_s(string)
|
||||
input + string
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -648,7 +694,9 @@ module Liquid
|
||||
# @liquid_syntax string | prepend: string
|
||||
# @liquid_return [string]
|
||||
def prepend(input, string)
|
||||
string.to_s + input.to_s
|
||||
input = Utils.to_s(input)
|
||||
string = Utils.to_s(string)
|
||||
string + input
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -659,10 +707,20 @@ module Liquid
|
||||
# @liquid_syntax string | newline_to_br
|
||||
# @liquid_return [string]
|
||||
def newline_to_br(input)
|
||||
input.to_s.gsub(/\r?\n/, "<br />\n")
|
||||
input = Utils.to_s(input)
|
||||
input.gsub(/\r?\n/, "<br />\n")
|
||||
end
|
||||
|
||||
# Reformat a date using Ruby's core Time#strftime( string ) -> string
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category date
|
||||
# @liquid_summary
|
||||
# Formats a date according to a specified format string.
|
||||
# @liquid_description
|
||||
# This filter formats a date using various format specifiers. If the format string is empty,
|
||||
# the original input is returned. If the input cannot be converted to a date, the original input is returned.
|
||||
#
|
||||
# The following format specifiers can be used:
|
||||
#
|
||||
# %a - The abbreviated weekday name (``Sun'')
|
||||
# %A - The full weekday name (``Sunday'')
|
||||
@@ -691,14 +749,15 @@ module Liquid
|
||||
# %Y - Year with century
|
||||
# %Z - Time zone name
|
||||
# %% - Literal ``%'' character
|
||||
#
|
||||
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
|
||||
# @liquid_syntax date | date: string
|
||||
# @liquid_return [string]
|
||||
def date(input, format)
|
||||
return input if format.to_s.empty?
|
||||
str_format = Utils.to_s(format)
|
||||
return input if str_format.empty?
|
||||
|
||||
return input unless (date = Utils.to_date(input))
|
||||
|
||||
date.strftime(format.to_s)
|
||||
date.strftime(str_format)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -918,8 +977,27 @@ module Liquid
|
||||
|
||||
attr_reader :context
|
||||
|
||||
def filter_array(input, property, target_value, default_value = [], &block)
|
||||
ary = InputIterator.new(input, context)
|
||||
|
||||
return default_value if ary.empty?
|
||||
|
||||
block.call(ary) do |item|
|
||||
if target_value.nil?
|
||||
item[property]
|
||||
else
|
||||
item[property] == target_value
|
||||
end
|
||||
rescue TypeError
|
||||
raise_property_error(property)
|
||||
rescue NoMethodError
|
||||
return nil unless item.respond_to?(:[])
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def raise_property_error(property)
|
||||
raise Liquid::ArgumentError, "cannot select the property '#{property}'"
|
||||
raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'"
|
||||
end
|
||||
|
||||
def apply_operation(input, operand, operation)
|
||||
@@ -968,7 +1046,18 @@ module Liquid
|
||||
end
|
||||
|
||||
def join(glue)
|
||||
to_a.join(glue.to_s)
|
||||
first = true
|
||||
output = +""
|
||||
each do |item|
|
||||
if first
|
||||
first = false
|
||||
else
|
||||
output << glue
|
||||
end
|
||||
|
||||
output << Liquid::Utils.to_s(item)
|
||||
end
|
||||
output
|
||||
end
|
||||
|
||||
def concat(args)
|
||||
@@ -980,7 +1069,10 @@ module Liquid
|
||||
end
|
||||
|
||||
def uniq(&block)
|
||||
to_a.uniq(&block)
|
||||
to_a.uniq do |item|
|
||||
item = Utils.to_liquid_value(item)
|
||||
block ? yield(item) : item
|
||||
end
|
||||
end
|
||||
|
||||
def compact
|
||||
|
||||
@@ -19,6 +19,7 @@ require_relative "tags/comment"
|
||||
require_relative "tags/raw"
|
||||
require_relative "tags/render"
|
||||
require_relative "tags/cycle"
|
||||
require_relative "tags/doc"
|
||||
|
||||
module Liquid
|
||||
module Tags
|
||||
@@ -42,6 +43,7 @@ module Liquid
|
||||
'if' => If,
|
||||
'echo' => Echo,
|
||||
'tablerow' => TableRow,
|
||||
'doc' => Doc,
|
||||
}.freeze
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,7 +68,13 @@ module Liquid
|
||||
def variables_from_string(markup)
|
||||
markup.split(',').collect do |var|
|
||||
var =~ /\s*(#{QuotedFragment})\s*/o
|
||||
Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil
|
||||
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))
|
||||
var.is_a?(VariableLookup) ? var.dup : var
|
||||
end.compact
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ module Liquid
|
||||
# @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
|
||||
# [snippets](/themes/architecture#snippets) included in the file.
|
||||
# [snippets](/themes/architecture/snippets) included in the file.
|
||||
#
|
||||
# Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
|
||||
# and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# @liquid_public_docs
|
||||
# @liquid_type tag
|
||||
# @liquid_category syntax
|
||||
# @liquid_name doc
|
||||
# @liquid_summary
|
||||
# Documents template elements with annotations.
|
||||
# @liquid_description
|
||||
# The `doc` tag allows developers to include documentation within Liquid
|
||||
# templates. Any content inside `doc` tags is not rendered or outputted.
|
||||
# Liquid code inside will be parsed but not executed. This facilitates
|
||||
# tooling support for features like code completion, linting, and inline
|
||||
# documentation.
|
||||
#
|
||||
# For detailed documentation syntax and examples, see the
|
||||
# [`LiquidDoc` reference](/docs/storefronts/themes/tools/liquid-doc).
|
||||
#
|
||||
# @liquid_syntax
|
||||
# {% doc %}
|
||||
# Renders a message.
|
||||
#
|
||||
# @param {string} foo - A string value.
|
||||
# @param {string} [bar] - An optional string value.
|
||||
#
|
||||
# @example
|
||||
# {% render 'message', foo: 'Hello', bar: 'World' %}
|
||||
# {% enddoc %}
|
||||
class Doc < Block
|
||||
NO_UNEXPECTED_ARGS = /\A\s*\z/
|
||||
|
||||
def initialize(tag_name, markup, parse_context)
|
||||
super
|
||||
ensure_valid_markup(tag_name, markup, parse_context)
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
while (token = tokens.shift)
|
||||
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
|
||||
|
||||
raise_nested_doc_error if tag_name == @tag_name
|
||||
|
||||
if tag_name == block_delimiter
|
||||
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
raise_tag_never_closed(block_name)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(_context, output)
|
||||
output
|
||||
end
|
||||
|
||||
def blank?
|
||||
true
|
||||
end
|
||||
|
||||
def nodelist
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_valid_markup(tag_name, markup, parse_context)
|
||||
unless NO_UNEXPECTED_ARGS.match?(markup)
|
||||
raise SyntaxError, parse_context.locale.t("errors.syntax.block_tag_unexpected_args", tag: tag_name)
|
||||
end
|
||||
end
|
||||
|
||||
def raise_nested_doc_error
|
||||
raise SyntaxError, parse_context.locale.t("errors.syntax.doc_invalid_nested")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -88,7 +88,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
p = Parser.new(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
@variable_name = p.consume(:id)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
p = Parser.new(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
condition = parse_binary_comparisons(p)
|
||||
p.consume(:end_of_string)
|
||||
condition
|
||||
|
||||
@@ -6,7 +6,7 @@ module Liquid
|
||||
# @liquid_category theme
|
||||
# @liquid_name include
|
||||
# @liquid_summary
|
||||
# Renders a [snippet](/themes/architecture#snippets).
|
||||
# Renders a [snippet](/themes/architecture/snippets).
|
||||
# @liquid_description
|
||||
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
|
||||
# snippet.
|
||||
|
||||
@@ -10,7 +10,7 @@ module Liquid
|
||||
# @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
|
||||
# [snippets](/themes/architecture#snippets) included in the file.
|
||||
# [snippets](/themes/architecture/snippets) included in the file.
|
||||
#
|
||||
# Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
|
||||
# and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share
|
||||
|
||||
@@ -6,7 +6,7 @@ module Liquid
|
||||
# @liquid_category theme
|
||||
# @liquid_name render
|
||||
# @liquid_summary
|
||||
# Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
|
||||
# Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
|
||||
# @liquid_description
|
||||
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
|
||||
# of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
|
||||
|
||||
@@ -205,6 +205,63 @@ module Liquid
|
||||
render(context, output: output)
|
||||
end
|
||||
|
||||
# Compile the template to pure Ruby code.
|
||||
#
|
||||
# Returns a string containing Ruby code that can be eval'd to create
|
||||
# a proc/lambda. The proc takes an assigns hash and returns the rendered
|
||||
# output string.
|
||||
#
|
||||
# This provides a way to convert Liquid templates to standalone Ruby code
|
||||
# that can be executed without the Liquid library at runtime.
|
||||
#
|
||||
# == Example
|
||||
#
|
||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||
# ruby_code = template.compile_to_ruby
|
||||
# render_proc = eval(ruby_code)
|
||||
# result = render_proc.call({ "name" => "World" })
|
||||
# # => "Hello, World!"
|
||||
#
|
||||
# == Options
|
||||
#
|
||||
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
|
||||
# * <tt>:include_filters</tt> - Include helper methods for filters (default: true)
|
||||
#
|
||||
# == Advantages of Compiled Code
|
||||
#
|
||||
# * No Context object overhead
|
||||
# * No filter invocation overhead (direct method calls)
|
||||
# * No resource limits tracking
|
||||
# * No stack-based scoping (uses Ruby's native scoping)
|
||||
# * No profiling hooks
|
||||
# * Direct string concatenation
|
||||
#
|
||||
# == Limitations
|
||||
#
|
||||
# * {% render %} and {% include %} tags require runtime support
|
||||
# * Custom tags need explicit compiler implementations
|
||||
# * Custom filters must be available at runtime
|
||||
#
|
||||
# Returns a CompiledTemplate object with the Ruby code and any external tags
|
||||
# that need to be passed to the generated lambda.
|
||||
#
|
||||
# Usage:
|
||||
# compiled = template.compile_to_ruby
|
||||
# result = compiled.call({ "name" => "World" }) # Handles external tags automatically
|
||||
#
|
||||
# Or manually:
|
||||
# proc = eval(compiled.code)
|
||||
# result = proc.call(assigns, compiled.external_tags)
|
||||
#
|
||||
def compile_to_ruby(options = {})
|
||||
return nil if @root.nil?
|
||||
|
||||
require 'liquid/compile'
|
||||
compiler = Compile::RubyCompiler.new(self, options)
|
||||
code = compiler.compile
|
||||
Compile::CompiledTemplate.new(code, compiler.external_tags, compiler.has_external_filters?)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure_options(options)
|
||||
|
||||
+132
-16
@@ -1,20 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "strscan"
|
||||
|
||||
module Liquid
|
||||
class Tokenizer
|
||||
attr_reader :line_number, :for_liquid_tag
|
||||
|
||||
def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false)
|
||||
@source = source
|
||||
@line_number = line_number || (line_numbers ? 1 : nil)
|
||||
TAG_END = /%\}/
|
||||
TAG_OR_VARIABLE_START = /\{[\{\%]/
|
||||
NEWLINE = /\n/
|
||||
|
||||
OPEN_CURLEY = "{".ord
|
||||
CLOSE_CURLEY = "}".ord
|
||||
PERCENTAGE = "%".ord
|
||||
|
||||
def initialize(
|
||||
source:,
|
||||
string_scanner:,
|
||||
line_numbers: false,
|
||||
line_number: nil,
|
||||
for_liquid_tag: false
|
||||
)
|
||||
@line_number = line_number || (line_numbers ? 1 : nil)
|
||||
@for_liquid_tag = for_liquid_tag
|
||||
@offset = 0
|
||||
@tokens = tokenize
|
||||
@source = source.to_s.to_str
|
||||
@offset = 0
|
||||
@tokens = []
|
||||
|
||||
if @source
|
||||
@ss = string_scanner
|
||||
@ss.string = @source
|
||||
tokenize
|
||||
end
|
||||
end
|
||||
|
||||
def shift
|
||||
token = @tokens[@offset]
|
||||
return nil unless token
|
||||
|
||||
return unless token
|
||||
|
||||
@offset += 1
|
||||
|
||||
@@ -28,18 +51,111 @@ module Liquid
|
||||
private
|
||||
|
||||
def tokenize
|
||||
return [] if @source.empty?
|
||||
|
||||
return @source.split("\n") if @for_liquid_tag
|
||||
|
||||
tokens = @source.split(TemplateParser)
|
||||
|
||||
# removes the rogue empty element at the beginning of the array
|
||||
if tokens[0]&.empty?
|
||||
@offset += 1
|
||||
if @for_liquid_tag
|
||||
@tokens = @source.split("\n")
|
||||
else
|
||||
@tokens << shift_normal until @ss.eos?
|
||||
end
|
||||
|
||||
tokens
|
||||
@source = nil
|
||||
@ss = nil
|
||||
end
|
||||
|
||||
def shift_normal
|
||||
token = next_token
|
||||
|
||||
return unless token
|
||||
|
||||
token
|
||||
end
|
||||
|
||||
def next_token
|
||||
# possible states: :text, :tag, :variable
|
||||
byte_a = @ss.peek_byte
|
||||
|
||||
if byte_a == OPEN_CURLEY
|
||||
@ss.scan_byte
|
||||
|
||||
byte_b = @ss.peek_byte
|
||||
|
||||
if byte_b == PERCENTAGE
|
||||
@ss.scan_byte
|
||||
return next_tag_token
|
||||
elsif byte_b == OPEN_CURLEY
|
||||
@ss.scan_byte
|
||||
return next_variable_token
|
||||
end
|
||||
|
||||
@ss.pos -= 1
|
||||
end
|
||||
|
||||
next_text_token
|
||||
end
|
||||
|
||||
def next_text_token
|
||||
start = @ss.pos
|
||||
|
||||
unless @ss.skip_until(TAG_OR_VARIABLE_START)
|
||||
token = @ss.rest
|
||||
@ss.terminate
|
||||
return token
|
||||
end
|
||||
|
||||
pos = @ss.pos -= 2
|
||||
@source.byteslice(start, pos - start)
|
||||
rescue ::ArgumentError => e
|
||||
if e.message == "invalid byte sequence in #{@ss.string.encoding}"
|
||||
raise SyntaxError, "Invalid byte sequence in #{@ss.string.encoding}"
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def next_variable_token
|
||||
start = @ss.pos - 2
|
||||
|
||||
byte_a = byte_b = @ss.scan_byte
|
||||
|
||||
while byte_b
|
||||
byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY)
|
||||
|
||||
break unless byte_a
|
||||
|
||||
if @ss.eos?
|
||||
return byte_a == CLOSE_CURLEY ? @source.byteslice(start, @ss.pos - start) : "{{"
|
||||
end
|
||||
|
||||
byte_b = @ss.scan_byte
|
||||
|
||||
if byte_a == CLOSE_CURLEY
|
||||
if byte_b == CLOSE_CURLEY
|
||||
return @source.byteslice(start, @ss.pos - start)
|
||||
elsif byte_b != CLOSE_CURLEY
|
||||
@ss.pos -= 1
|
||||
return @source.byteslice(start, @ss.pos - start)
|
||||
end
|
||||
elsif byte_a == OPEN_CURLEY && byte_b == PERCENTAGE
|
||||
return next_tag_token_with_start(start)
|
||||
end
|
||||
|
||||
byte_a = byte_b
|
||||
end
|
||||
|
||||
"{{"
|
||||
end
|
||||
|
||||
def next_tag_token
|
||||
start = @ss.pos - 2
|
||||
if (len = @ss.skip_until(TAG_END))
|
||||
@source.byteslice(start, len + 2)
|
||||
else
|
||||
"{%"
|
||||
end
|
||||
end
|
||||
|
||||
def next_tag_token_with_start(start)
|
||||
@ss.skip_until(TAG_END)
|
||||
@source.byteslice(start, @ss.pos - start)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,5 +89,101 @@ module Liquid
|
||||
# Otherwise return the object itself
|
||||
obj
|
||||
end
|
||||
|
||||
def self.to_s(obj, seen = {})
|
||||
case obj
|
||||
when Hash
|
||||
# If the custom hash implementation overrides `#to_s`, use their
|
||||
# custom implementation. Otherwise we use Liquid's default
|
||||
# implementation.
|
||||
if obj.class.instance_method(:to_s) == HASH_TO_S_METHOD
|
||||
hash_inspect(obj, seen)
|
||||
else
|
||||
obj.to_s
|
||||
end
|
||||
when Array
|
||||
array_inspect(obj, seen)
|
||||
else
|
||||
obj.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def self.inspect(obj, seen = {})
|
||||
case obj
|
||||
when Hash
|
||||
# If the custom hash implementation overrides `#inspect`, use their
|
||||
# custom implementation. Otherwise we use Liquid's default
|
||||
# implementation.
|
||||
if obj.class.instance_method(:inspect) == HASH_INSPECT_METHOD
|
||||
hash_inspect(obj, seen)
|
||||
else
|
||||
obj.inspect
|
||||
end
|
||||
when Array
|
||||
array_inspect(obj, seen)
|
||||
else
|
||||
obj.inspect
|
||||
end
|
||||
end
|
||||
|
||||
def self.array_inspect(arr, seen = {})
|
||||
if seen[arr.object_id]
|
||||
return "[...]"
|
||||
end
|
||||
|
||||
seen[arr.object_id] = true
|
||||
str = +"["
|
||||
cursor = 0
|
||||
len = arr.length
|
||||
|
||||
while cursor < len
|
||||
if cursor > 0
|
||||
str << ", "
|
||||
end
|
||||
|
||||
item_str = inspect(arr[cursor], seen)
|
||||
str << item_str
|
||||
cursor += 1
|
||||
end
|
||||
|
||||
str << "]"
|
||||
str
|
||||
ensure
|
||||
seen.delete(arr.object_id)
|
||||
end
|
||||
|
||||
def self.hash_inspect(hash, seen = {})
|
||||
if seen[hash.object_id]
|
||||
return "{...}"
|
||||
end
|
||||
seen[hash.object_id] = true
|
||||
|
||||
str = +"{"
|
||||
first = true
|
||||
hash.each do |key, value|
|
||||
if first
|
||||
first = false
|
||||
else
|
||||
str << ", "
|
||||
end
|
||||
|
||||
key_str = inspect(key, seen)
|
||||
str << key_str
|
||||
str << "=>"
|
||||
|
||||
value_str = inspect(value, seen)
|
||||
str << value_str
|
||||
end
|
||||
str << "}"
|
||||
str
|
||||
ensure
|
||||
seen.delete(hash.object_id)
|
||||
end
|
||||
|
||||
HASH_TO_S_METHOD = Hash.instance_method(:to_s)
|
||||
private_constant :HASH_TO_S_METHOD
|
||||
|
||||
HASH_INSPECT_METHOD = Hash.instance_method(:inspect)
|
||||
private_constant :HASH_INSPECT_METHOD
|
||||
end
|
||||
end
|
||||
|
||||
+15
-9
@@ -61,7 +61,7 @@ module Liquid
|
||||
|
||||
def strict_parse(markup)
|
||||
@filters = []
|
||||
p = Parser.new(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@@ -95,17 +95,23 @@ module Liquid
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
obj = render(context)
|
||||
|
||||
if obj.is_a?(Array)
|
||||
output << obj.join
|
||||
elsif obj.nil?
|
||||
else
|
||||
output << obj.to_s
|
||||
end
|
||||
|
||||
render_obj_to_output(obj, output)
|
||||
output
|
||||
end
|
||||
|
||||
def render_obj_to_output(obj, output)
|
||||
case obj
|
||||
when NilClass
|
||||
# Do nothing
|
||||
when Array
|
||||
obj.each do |o|
|
||||
render_obj_to_output(o, output)
|
||||
end
|
||||
else
|
||||
output << Liquid::Utils.to_s(obj)
|
||||
end
|
||||
end
|
||||
|
||||
def disabled?(_context)
|
||||
false
|
||||
end
|
||||
|
||||
@@ -6,16 +6,20 @@ module Liquid
|
||||
|
||||
attr_reader :name, :lookups
|
||||
|
||||
def self.parse(markup)
|
||||
new(markup)
|
||||
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
|
||||
new(markup, string_scanner, cache)
|
||||
end
|
||||
|
||||
def initialize(markup)
|
||||
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
|
||||
lookups = markup.scan(VariableParser)
|
||||
|
||||
name = lookups.shift
|
||||
if name&.start_with?('[') && name&.end_with?(']')
|
||||
name = Expression.parse(name[1..-2])
|
||||
name = Expression.parse(
|
||||
name[1..-2],
|
||||
string_scanner,
|
||||
cache,
|
||||
)
|
||||
end
|
||||
@name = name
|
||||
|
||||
@@ -25,7 +29,11 @@ module Liquid
|
||||
@lookups.each_index do |i|
|
||||
lookup = lookups[i]
|
||||
if lookup&.start_with?('[') && lookup&.end_with?(']')
|
||||
lookups[i] = Expression.parse(lookup[1..-2])
|
||||
lookups[i] = Expression.parse(
|
||||
lookup[1..-2],
|
||||
string_scanner,
|
||||
cache,
|
||||
)
|
||||
elsif COMMAND_METHODS.include?(lookup)
|
||||
@command_flags |= 1 << i
|
||||
end
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
VERSION = "5.6.0.rc1"
|
||||
VERSION = "5.8.6"
|
||||
end
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
|
||||
s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
|
||||
s.authors = ["Tobias Lütke"]
|
||||
s.email = ["[email protected]"]
|
||||
s.homepage = "http://www.liquidmarkup.org"
|
||||
s.homepage = "https://shopify.github.io/liquid/"
|
||||
s.license = "MIT"
|
||||
# s.description = "A secure, non-evaling end user template engine with aesthetic markup."
|
||||
|
||||
@@ -28,7 +28,7 @@ Gem::Specification.new do |s|
|
||||
|
||||
s.require_path = "lib"
|
||||
|
||||
s.add_dependency("strscan")
|
||||
s.add_dependency("strscan", ">= 3.1.1")
|
||||
s.add_dependency("bigdecimal")
|
||||
|
||||
s.add_development_dependency('rake', '~> 13.0')
|
||||
|
||||
@@ -9,14 +9,17 @@ Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
|
||||
profiler = ThemeRunner.new
|
||||
|
||||
Benchmark.ips do |x|
|
||||
x.time = 10
|
||||
x.warmup = 5
|
||||
x.time = 20
|
||||
x.warmup = 10
|
||||
|
||||
puts
|
||||
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
|
||||
puts
|
||||
|
||||
x.report("parse:") { profiler.compile }
|
||||
x.report("render:") { profiler.render }
|
||||
x.report("parse & render:") { profiler.run }
|
||||
phase = ENV["PHASE"] || "all"
|
||||
|
||||
x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize"
|
||||
x.report("parse:") { profiler.compile } if phase == "all" || phase == "parse"
|
||||
x.report("render:") { profiler.render } if phase == "all" || phase == "render"
|
||||
x.report("parse & render:") { profiler.run } if phase == "all" || phase == "run"
|
||||
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,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
|
||||
@@ -48,6 +48,19 @@ class ThemeRunner
|
||||
end
|
||||
end
|
||||
|
||||
# `tokenize` will just test the tokenizen portion of liquid without any templates
|
||||
def tokenize
|
||||
ss = StringScanner.new("")
|
||||
@tests.each do |test_hash|
|
||||
tokenizer = Liquid::Tokenizer.new(
|
||||
source: test_hash[:liquid],
|
||||
string_scanner: ss,
|
||||
line_numbers: true,
|
||||
)
|
||||
while tokenizer.shift; end
|
||||
end
|
||||
end
|
||||
|
||||
# `run` is called to benchmark rendering and compiling at the same time
|
||||
def run
|
||||
each_test do |liquid, layout, assigns, page_template, template_name|
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "benchmark/ips"
|
||||
|
||||
# benchmark liquid lexing
|
||||
|
||||
require 'liquid'
|
||||
|
||||
RubyVM::YJIT.enable
|
||||
|
||||
STRING_MARKUPS = [
|
||||
"\"foo\"",
|
||||
"\"fooooooooooo\"",
|
||||
"\"foooooooooooooooooooooooooooooo\"",
|
||||
"'foo'",
|
||||
"'fooooooooooo'",
|
||||
"'foooooooooooooooooooooooooooooo'",
|
||||
]
|
||||
|
||||
VARIABLE_MARKUPS = [
|
||||
"article",
|
||||
"article.title",
|
||||
"article.title.size",
|
||||
"very_long_variable_name_2024_11_05",
|
||||
"very_long_variable_name_2024_11_05.size",
|
||||
]
|
||||
|
||||
NUMBER_MARKUPS = [
|
||||
"0",
|
||||
"35",
|
||||
"1241891024912849",
|
||||
"3.5",
|
||||
"3.51214128409128",
|
||||
"12381902839.123819283910283",
|
||||
"123.456.789",
|
||||
"-123",
|
||||
"-12.33",
|
||||
"-405.231",
|
||||
"-0",
|
||||
"0",
|
||||
"0.0",
|
||||
"0.0000000000000000000000",
|
||||
"0.00000000001",
|
||||
]
|
||||
|
||||
RANGE_MARKUPS = [
|
||||
"(1..30)",
|
||||
"(1...30)",
|
||||
"(1..30..5)",
|
||||
"(1.0...30.0)",
|
||||
"(1.........30)",
|
||||
"(1..foo)",
|
||||
"(foo..30)",
|
||||
"(foo..bar)",
|
||||
"(foo...bar...100)",
|
||||
"(foo...bar...100.0)",
|
||||
]
|
||||
|
||||
LITERAL_MARKUPS = [
|
||||
nil,
|
||||
'nil',
|
||||
'null',
|
||||
'',
|
||||
'true',
|
||||
'false',
|
||||
'blank',
|
||||
'empty',
|
||||
]
|
||||
|
||||
MARKUPS = {
|
||||
"string" => STRING_MARKUPS,
|
||||
"literal" => LITERAL_MARKUPS,
|
||||
"variable" => VARIABLE_MARKUPS,
|
||||
"number" => NUMBER_MARKUPS,
|
||||
"range" => RANGE_MARKUPS,
|
||||
}
|
||||
|
||||
Benchmark.ips do |x|
|
||||
x.config(time: 5, warmup: 5)
|
||||
|
||||
MARKUPS.each do |type, markups|
|
||||
x.report("Liquid::Expression#parse: #{type}") do
|
||||
markups.each do |markup|
|
||||
Liquid::Expression.parse(markup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
x.report("Liquid::Expression#parse: all") do
|
||||
MARKUPS.values.flatten.each do |markup|
|
||||
Liquid::Expression.parse(markup)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,31 +29,12 @@ EXPRESSIONS = [
|
||||
"foo | default: -1",
|
||||
]
|
||||
|
||||
EXPRESSIONS.each do |expr|
|
||||
lexer_1_result = Liquid::Lexer1.new(expr).tokenize
|
||||
lexer_2_result = Liquid::Lexer2.new(expr).tokenize
|
||||
|
||||
next if lexer_1_result == lexer_2_result
|
||||
|
||||
warn "Lexer1 and Lexer2 results are different for expression: #{expr}"
|
||||
warn "expected: #{lexer_1_result}"
|
||||
warn "got: #{lexer_2_result}"
|
||||
abort
|
||||
end
|
||||
|
||||
Benchmark.ips do |x|
|
||||
x.config(time: 10, warmup: 5)
|
||||
|
||||
x.report("Liquid::Lexer1#tokenize") do
|
||||
x.report("Liquid::Lexer#tokenize") do
|
||||
EXPRESSIONS.each do |expr|
|
||||
l = Liquid::Lexer1.new(expr)
|
||||
l.tokenize
|
||||
end
|
||||
end
|
||||
|
||||
x.report("Liquid::Lexer2#tokenize") do
|
||||
EXPRESSIONS.each do |expr|
|
||||
l = Liquid::Lexer2.new(expr)
|
||||
l = Liquid::Lexer.new(expr)
|
||||
l.tokenize
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
require 'lru_redux'
|
||||
|
||||
class ExpressionTest < Minitest::Test
|
||||
def test_keyword_literals
|
||||
@@ -13,6 +14,7 @@ class ExpressionTest < Minitest::Test
|
||||
assert_template_result("double quoted", '{{"double quoted"}}')
|
||||
assert_template_result("spaced", "{{ 'spaced' }}")
|
||||
assert_template_result("spaced2", "{{ 'spaced2' }}")
|
||||
assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
|
||||
end
|
||||
|
||||
def test_int
|
||||
@@ -22,8 +24,18 @@ class ExpressionTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_float
|
||||
assert_template_result("-17.42", "{{ -17.42 }}")
|
||||
assert_template_result("2.5", "{{ 2.5 }}")
|
||||
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
|
||||
result = Expression.parse(".5")
|
||||
assert_kind_of(Liquid::VariableLookup, result)
|
||||
|
||||
result = Expression.parse("-.5")
|
||||
assert_kind_of(Liquid::VariableLookup, result)
|
||||
end
|
||||
|
||||
def test_range
|
||||
@@ -40,6 +52,101 @@ class ExpressionTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_quirky_negative_sign_expression_markup
|
||||
result = Expression.parse("-", nil)
|
||||
assert(result.is_a?(VariableLookup))
|
||||
assert_equal("-", result.name)
|
||||
|
||||
# for this template, the expression markup is "-"
|
||||
assert_template_result(
|
||||
"",
|
||||
"{{ - 'theme.css' - }}",
|
||||
)
|
||||
end
|
||||
|
||||
def test_expression_cache
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
cache = {}
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template, expression_cache: cache).render
|
||||
|
||||
assert_equal(
|
||||
["1", "2", "x", "y"],
|
||||
cache.to_a.map { _1[0] }.sort,
|
||||
)
|
||||
end
|
||||
|
||||
def test_expression_cache_with_true_boolean
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
parse_context = ParseContext.new(expression_cache: true)
|
||||
|
||||
Liquid::Template.parse(template, parse_context).render
|
||||
|
||||
cache = parse_context.instance_variable_get(:@expression_cache)
|
||||
|
||||
assert_equal(
|
||||
["1", "2", "x", "y"],
|
||||
cache.to_a.map { _1[0] }.sort,
|
||||
)
|
||||
end
|
||||
|
||||
def test_expression_cache_with_lru_redux
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
cache = LruRedux::Cache.new(10)
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template, expression_cache: cache).render
|
||||
|
||||
assert_equal(
|
||||
["1", "2", "x", "y"],
|
||||
cache.to_a.map { _1[0] }.sort,
|
||||
)
|
||||
end
|
||||
|
||||
def test_disable_expression_cache
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
parse_context = Liquid::ParseContext.new(expression_cache: false)
|
||||
Liquid::Template.parse(template, parse_context).render
|
||||
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_expression_result(expect, markup, **assigns)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class HashRenderingTest < Minitest::Test
|
||||
def test_render_empty_hash
|
||||
assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} })
|
||||
end
|
||||
|
||||
def test_render_hash_with_string_keys_and_values
|
||||
assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_symbol_keys_and_integer_values
|
||||
assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } })
|
||||
end
|
||||
|
||||
def test_render_nested_hash
|
||||
assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_array_values
|
||||
assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } })
|
||||
end
|
||||
|
||||
def test_render_recursive_hash
|
||||
recursive_hash = { "self" => {} }
|
||||
recursive_hash["self"]["self"] = recursive_hash
|
||||
assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash })
|
||||
end
|
||||
|
||||
def test_hash_with_downcase_filter
|
||||
assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_upcase_filter
|
||||
assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_strip_filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_escape_filter
|
||||
assert_template_result("{"Key"=>"Value", "AnotherKey"=>"AnotherValue"}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_url_encode_filter
|
||||
assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_strip_html_filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_truncate__20_filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_replace___key____replaced_key__filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_append____appended_text__filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_prepend___prepended_text___filter
|
||||
assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_array_values_empty
|
||||
assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_array_values_hash
|
||||
assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } })
|
||||
end
|
||||
|
||||
def test_join_filter_with_hash
|
||||
array = [{ "key1" => "value1" }, { "key2" => "value2" }]
|
||||
glue = { "lol" => "wut" }
|
||||
assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue })
|
||||
end
|
||||
|
||||
def test_render_hash_with_hash_key
|
||||
assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } })
|
||||
end
|
||||
|
||||
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
|
||||
my_hash = Class.new(Hash) do
|
||||
def to_s
|
||||
"kewl"
|
||||
end
|
||||
end.new
|
||||
|
||||
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash })
|
||||
end
|
||||
|
||||
def test_rendering_hash_without_custom_to_s_uses_default_inspect
|
||||
my_hash = Class.new(Hash).new
|
||||
my_hash[:foo] = :bar
|
||||
|
||||
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
|
||||
end
|
||||
end
|
||||
@@ -134,6 +134,14 @@ class ParsingQuirksTest < Minitest::Test
|
||||
|
||||
def test_incomplete_expression
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result("false", "{{ false - }}")
|
||||
assert_template_result("false", "{{ false > }}")
|
||||
assert_template_result("false", "{{ false < }}")
|
||||
assert_template_result("false", "{{ false = }}")
|
||||
assert_template_result("false", "{{ false ! }}")
|
||||
assert_template_result("false", "{{ false 1 }}")
|
||||
assert_template_result("false", "{{ false a }}")
|
||||
|
||||
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
|
||||
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
|
||||
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
|
||||
|
||||
@@ -133,6 +133,18 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal([], @filters.slice(input, -(1 << 63), 6))
|
||||
end
|
||||
|
||||
def test_find_on_empty_array
|
||||
assert_nil(@filters.find([], 'foo', 'bar'))
|
||||
end
|
||||
|
||||
def test_find_index_on_empty_array
|
||||
assert_nil(@filters.find_index([], 'foo', 'bar'))
|
||||
end
|
||||
|
||||
def test_has_on_empty_array
|
||||
refute(@filters.has([], 'foo', 'bar'))
|
||||
end
|
||||
|
||||
def test_truncate
|
||||
assert_equal('1234...', @filters.truncate('1234567890', 7))
|
||||
assert_equal('1234567890', @filters.truncate('1234567890', 20))
|
||||
@@ -281,6 +293,16 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal('1121314', @filters.join([1, 2, 3, 4], 1))
|
||||
end
|
||||
|
||||
def test_join_calls_to_liquid_on_each_element
|
||||
drop = Class.new(Liquid::Drop) do
|
||||
def to_liquid
|
||||
'i did it'
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", "))
|
||||
end
|
||||
|
||||
def test_sort
|
||||
assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1]))
|
||||
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
|
||||
@@ -538,12 +560,23 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
|
||||
def test_map_returns_empty_with_no_property
|
||||
def test_map_with_value_property
|
||||
array = [
|
||||
{ "handle" => "alpha", "value" => "A" },
|
||||
{ "handle" => "beta", "value" => "B" },
|
||||
{ "handle" => "gamma", "value" => "C" }
|
||||
]
|
||||
|
||||
assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array })
|
||||
end
|
||||
|
||||
def test_map_returns_input_with_no_property
|
||||
foo = [
|
||||
[1],
|
||||
[2],
|
||||
[3],
|
||||
]
|
||||
|
||||
assert_raises(Liquid::ArgumentError) do
|
||||
@filters.map(foo, nil)
|
||||
end
|
||||
@@ -827,21 +860,233 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_template_result('abc', "{{ 'abc' | date: '%D' }}")
|
||||
end
|
||||
|
||||
def test_where
|
||||
input = [
|
||||
def test_reject
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
expectation = [
|
||||
template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "beta gamma"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_reject_with_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
assert_equal(expectation, @filters.where(input, "ok", true))
|
||||
assert_equal(expectation, @filters.where(input, "ok"))
|
||||
template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "beta gamma"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_reject_with_false_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "alpha delta"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_has
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => false },
|
||||
]
|
||||
|
||||
expected_output = "true"
|
||||
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
|
||||
end
|
||||
|
||||
def test_has_when_does_not_have_it
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => false },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => false },
|
||||
]
|
||||
|
||||
expected_output = "false"
|
||||
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
|
||||
end
|
||||
|
||||
def test_has_with_empty_arrays
|
||||
template = <<~LIQUID
|
||||
{%- assign has_product = products | has: 'title.content', 'Not found' -%}
|
||||
{%- unless has_product -%}
|
||||
Product not found.
|
||||
{%- endunless -%}
|
||||
LIQUID
|
||||
expected_output = "Product not found."
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => [] })
|
||||
end
|
||||
|
||||
def test_has_with_false_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | has: 'ok', false }}"
|
||||
expected_output = "true"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_has_with_false_value_when_does_not_have_it
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => true },
|
||||
{ "handle" => "gamma", "ok" => true },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | has: 'ok', false }}"
|
||||
expected_output = "false"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_find_with_value
|
||||
products = [
|
||||
{ "title" => "Pro goggles", "price" => 1299 },
|
||||
{ "title" => "Thermal gloves", "price" => 1499 },
|
||||
{ "title" => "Alpine jacket", "price" => 3999 },
|
||||
{ "title" => "Mountain boots", "price" => 3899 },
|
||||
{ "title" => "Safety helmet", "price" => 1999 }
|
||||
]
|
||||
|
||||
template = <<~LIQUID
|
||||
{%- assign product = products | find: 'price', 3999 -%}
|
||||
{{- product.title -}}
|
||||
LIQUID
|
||||
expected_output = "Alpine jacket"
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => products })
|
||||
end
|
||||
|
||||
def test_find_with_empty_arrays
|
||||
template = <<~LIQUID
|
||||
{%- assign product = products | find: 'title.content', 'Not found' -%}
|
||||
{%- unless product -%}
|
||||
Product not found.
|
||||
{%- endunless -%}
|
||||
LIQUID
|
||||
expected_output = "Product not found."
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => [] })
|
||||
end
|
||||
|
||||
def test_find_index_with_value
|
||||
products = [
|
||||
{ "title" => "Pro goggles", "price" => 1299 },
|
||||
{ "title" => "Thermal gloves", "price" => 1499 },
|
||||
{ "title" => "Alpine jacket", "price" => 3999 },
|
||||
{ "title" => "Mountain boots", "price" => 3899 },
|
||||
{ "title" => "Safety helmet", "price" => 1999 }
|
||||
]
|
||||
|
||||
template = <<~LIQUID
|
||||
{%- assign index = products | find_index: 'price', 3999 -%}
|
||||
{{- index -}}
|
||||
LIQUID
|
||||
expected_output = "2"
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => products })
|
||||
end
|
||||
|
||||
def test_find_index_with_empty_arrays
|
||||
template = <<~LIQUID
|
||||
{%- assign index = products | find_index: 'title.content', 'Not found' -%}
|
||||
{%- unless index -%}
|
||||
Index not found.
|
||||
{%- endunless -%}
|
||||
LIQUID
|
||||
expected_output = "Index not found."
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => [] })
|
||||
end
|
||||
|
||||
def test_where
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "alpha delta"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_where_with_empty_string_is_a_no_op
|
||||
environment = { "array" => ["alpha", "beta", "gamma"] }
|
||||
expected_output = "alpha beta gamma"
|
||||
template = "{{ array | where: '' | join: ' ' }}"
|
||||
|
||||
assert_template_result(expected_output, template, environment)
|
||||
end
|
||||
|
||||
def test_where_with_nil_is_a_no_op
|
||||
environment = { "array" => ["alpha", "beta", "gamma"] }
|
||||
template = "{{ array | where: nil | join: ' ' }}"
|
||||
|
||||
assert_raises(Liquid::ArgumentError) do
|
||||
assert_template_result("alpha beta gamma", template, environment)
|
||||
end
|
||||
end
|
||||
|
||||
def test_where_with_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "alpha delta"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_where_with_false_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "beta gamma"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_where_string_keys
|
||||
@@ -1051,6 +1296,33 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
|
||||
end
|
||||
|
||||
def test_sum_with_non_string_property
|
||||
input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }]
|
||||
|
||||
assert_equal(1, @filters.sum(input, true))
|
||||
assert_equal(0.2, @filters.sum(input, 1.0))
|
||||
assert_equal(-0.3, @filters.sum(input, 1))
|
||||
assert_equal(0.4, @filters.sum(input, (1..5)))
|
||||
assert_equal(0, @filters.sum(input, nil))
|
||||
assert_equal(0, @filters.sum(input, ""))
|
||||
end
|
||||
|
||||
def test_uniq_with_to_liquid_value
|
||||
input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"]
|
||||
expected = [StringDrop.new("foo"), StringDrop.new("bar")]
|
||||
result = @filters.uniq(input)
|
||||
|
||||
assert_equal(expected, result)
|
||||
end
|
||||
|
||||
def test_uniq_with_to_liquid_value_pick_correct_classes
|
||||
input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")]
|
||||
expected = [String, StringDrop]
|
||||
result = @filters.uniq(input).map(&:class)
|
||||
|
||||
assert_equal(expected, result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_timezone(tz)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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' %}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_cycle_with_variables_inside_for_loop
|
||||
template = <<~LIQUID
|
||||
{%- assign a = 1 -%}
|
||||
{%- assign b = 2 -%}
|
||||
{%- assign c = 3 -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{% cycle a, b, c %}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_cycle_tag_always_resets_cycle
|
||||
template = <<~LIQUID
|
||||
{%- assign a = "1" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("11", template)
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,7 @@ class RawTagTest < Minitest::Test
|
||||
assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <')
|
||||
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <")
|
||||
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <")
|
||||
assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}")
|
||||
end
|
||||
|
||||
def test_open_tag_in_raw
|
||||
|
||||
@@ -130,6 +130,10 @@ class VariableTest < Minitest::Test
|
||||
assert_template_result('bar', '{{ foo }}', { 'foo' => :bar })
|
||||
end
|
||||
|
||||
def test_nested_array
|
||||
assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] })
|
||||
end
|
||||
|
||||
def test_dynamic_find_var
|
||||
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
|
||||
end
|
||||
|
||||
+29
-5
@@ -15,11 +15,6 @@ if (env_mode = ENV['LIQUID_PARSER_MODE'])
|
||||
end
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
|
||||
if ENV['LIQUID_C'] == '1'
|
||||
puts "-- LIQUID C"
|
||||
require 'liquid/c'
|
||||
end
|
||||
|
||||
if Minitest.const_defined?('Test')
|
||||
# We're on Minitest 5+. Nothing to do here.
|
||||
else
|
||||
@@ -151,6 +146,35 @@ class BooleanDrop < Liquid::Drop
|
||||
end
|
||||
end
|
||||
|
||||
class StringDrop < Liquid::Drop
|
||||
include Comparable
|
||||
|
||||
def initialize(value)
|
||||
super()
|
||||
@value = value
|
||||
end
|
||||
|
||||
def to_liquid_value
|
||||
@value
|
||||
end
|
||||
|
||||
def to_s
|
||||
@value
|
||||
end
|
||||
|
||||
def to_str
|
||||
@value
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<StringDrop @value=#{@value.inspect}>"
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
to_liquid_value <=> Liquid::Utils.to_liquid_value(other)
|
||||
end
|
||||
end
|
||||
|
||||
class ErrorDrop < Liquid::Drop
|
||||
def standard_error
|
||||
raise Liquid::StandardError, 'standard error'
|
||||
|
||||
@@ -47,12 +47,18 @@ class BlockUnitTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_with_block
|
||||
def test_comment_tag_with_block
|
||||
template = Liquid::Template.parse(" {% comment %} {% endcomment %} ")
|
||||
assert_equal([String, Comment, String], block_types(template.root.nodelist))
|
||||
assert_equal(3, template.root.nodelist.size)
|
||||
end
|
||||
|
||||
def test_doc_tag_with_block
|
||||
template = Liquid::Template.parse(" {% doc %} {% enddoc %} ")
|
||||
assert_equal([String, Doc, String], block_types(template.root.nodelist))
|
||||
assert_equal(3, template.root.nodelist.size)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def block_types(nodelist)
|
||||
|
||||
@@ -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
|
||||
@@ -36,14 +36,14 @@ class EnvironmentFilterTest < Minitest::Test
|
||||
assert_equal("public", strainer.invoke("public_filter"))
|
||||
end
|
||||
|
||||
def test_stainer_raises_argument_error
|
||||
def test_strainer_raises_argument_error
|
||||
strainer = @environment.create_strainer(@context)
|
||||
assert_raises(Liquid::ArgumentError) do
|
||||
strainer.invoke("public_filter", 1)
|
||||
end
|
||||
end
|
||||
|
||||
def test_stainer_argument_error_contains_backtrace
|
||||
def test_strainer_argument_error_contains_backtrace
|
||||
strainer = @environment.create_strainer(@context)
|
||||
|
||||
exception = assert_raises(Liquid::ArgumentError) do
|
||||
@@ -54,8 +54,9 @@ class EnvironmentFilterTest < Minitest::Test
|
||||
/\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/,
|
||||
exception.message,
|
||||
)
|
||||
|
||||
source = AccessScopeFilters.instance_method(:public_filter).source_location
|
||||
assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1])
|
||||
assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1])
|
||||
end
|
||||
|
||||
def test_strainer_only_invokes_public_filter_methods
|
||||
|
||||
@@ -131,9 +131,19 @@ class LexerUnitTest < Minitest::Test
|
||||
assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1"))
|
||||
end
|
||||
|
||||
def test_error_with_invalid_utf8
|
||||
error = assert_raises(SyntaxError) do
|
||||
tokenize("\x00\xff")
|
||||
end
|
||||
assert_equal(
|
||||
'Liquid syntax error: Invalid byte sequence in UTF-8',
|
||||
error.message,
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tokenize(input)
|
||||
Lexer.new(input).tokenize
|
||||
Lexer.tokenize(StringScanner.new(input))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_consume
|
||||
p = Parser.new("wat: 7")
|
||||
p = new_parser("wat: 7")
|
||||
assert_equal('wat', p.consume(:id))
|
||||
assert_equal(':', p.consume(:colon))
|
||||
assert_equal('7', p.consume(:number))
|
||||
end
|
||||
|
||||
def test_jump
|
||||
p = Parser.new("wat: 7")
|
||||
p = new_parser("wat: 7")
|
||||
p.jump(2)
|
||||
assert_equal('7', p.consume(:number))
|
||||
end
|
||||
|
||||
def test_consume?
|
||||
p = Parser.new("wat: 7")
|
||||
p = new_parser("wat: 7")
|
||||
assert_equal('wat', p.consume?(:id))
|
||||
assert_equal(false, p.consume?(:dot))
|
||||
assert_equal(':', p.consume(:colon))
|
||||
@@ -27,7 +27,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_id?
|
||||
p = Parser.new("wat 6 Peter Hegemon")
|
||||
p = new_parser("wat 6 Peter Hegemon")
|
||||
assert_equal('wat', p.id?('wat'))
|
||||
assert_equal(false, p.id?('endgame'))
|
||||
assert_equal('6', p.consume(:number))
|
||||
@@ -36,7 +36,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_look
|
||||
p = Parser.new("wat 6 Peter Hegemon")
|
||||
p = new_parser("wat 6 Peter Hegemon")
|
||||
assert_equal(true, p.look(:id))
|
||||
assert_equal('wat', p.consume(:id))
|
||||
assert_equal(false, p.look(:comparison))
|
||||
@@ -46,12 +46,12 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_expressions
|
||||
p = Parser.new("hi.there hi?[5].there? hi.there.bob")
|
||||
p = new_parser("hi.there hi?[5].there? hi.there.bob")
|
||||
assert_equal('hi.there', p.expression)
|
||||
assert_equal('hi?[5].there?', p.expression)
|
||||
assert_equal('hi.there.bob', p.expression)
|
||||
|
||||
p = Parser.new("567 6.0 'lol' \"wut\"")
|
||||
p = new_parser("567 6.0 'lol' \"wut\"")
|
||||
assert_equal('567', p.expression)
|
||||
assert_equal('6.0', p.expression)
|
||||
assert_equal("'lol'", p.expression)
|
||||
@@ -59,7 +59,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_ranges
|
||||
p = Parser.new("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
|
||||
p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
|
||||
assert_equal('(5..7)', p.expression)
|
||||
assert_equal('(1.5..9.6)', p.expression)
|
||||
assert_equal('(young..old)', p.expression)
|
||||
@@ -67,7 +67,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_arguments
|
||||
p = Parser.new("filter: hi.there[5], keyarg: 7")
|
||||
p = new_parser("filter: hi.there[5], keyarg: 7")
|
||||
assert_equal('filter', p.consume(:id))
|
||||
assert_equal(':', p.consume(:colon))
|
||||
assert_equal('hi.there[5]', p.argument)
|
||||
@@ -77,8 +77,14 @@ class ParserUnitTest < Minitest::Test
|
||||
|
||||
def test_invalid_expression
|
||||
assert_raises(SyntaxError) do
|
||||
p = Parser.new("==")
|
||||
p = new_parser("==")
|
||||
p.expression
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_parser(str)
|
||||
Parser.new(StringScanner.new(str))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,18 +6,18 @@ class TagUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_tag
|
||||
tag = Tag.parse('tag', "", Tokenizer.new(""), ParseContext.new)
|
||||
tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new)
|
||||
assert_equal('liquid::tag', tag.name)
|
||||
assert_equal('', tag.render(Context.new))
|
||||
end
|
||||
|
||||
def test_return_raw_text_of_tag
|
||||
tag = Tag.parse("long_tag", "param1, param2, param3", Tokenizer.new(""), ParseContext.new)
|
||||
tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new)
|
||||
assert_equal("long_tag param1, param2, param3", tag.raw)
|
||||
end
|
||||
|
||||
def test_tag_name_should_return_name_of_the_tag
|
||||
tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
|
||||
tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new)
|
||||
assert_equal('some_tag', tag.tag_name)
|
||||
end
|
||||
|
||||
@@ -26,7 +26,16 @@ class TagUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_tag_render_to_output_buffer_nil_value
|
||||
custom_tag = CustomTag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
|
||||
custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new)
|
||||
assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_tokenizer
|
||||
Tokenizer.new(
|
||||
source: "",
|
||||
string_scanner: StringScanner.new(""),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class DocTagUnitTest < Minitest::Test
|
||||
def test_doc_tag
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
Renders loading-spinner.
|
||||
|
||||
@param {string} foo - some foo
|
||||
@param {string} [bar] - optional bar
|
||||
|
||||
@example
|
||||
{% render 'loading-spinner', foo: 'foo' %}
|
||||
{% render 'loading-spinner', foo: 'foo', bar: 'bar' %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_does_not_support_extra_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc extra %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template)
|
||||
end
|
||||
|
||||
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}"
|
||||
act_error = error.message
|
||||
|
||||
assert_equal(exp_error, act_error)
|
||||
end
|
||||
|
||||
def test_doc_tag_must_support_valid_tags
|
||||
assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo')
|
||||
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}')
|
||||
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}')
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_liquid_nodes
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% if true %}
|
||||
{% if ... %}
|
||||
{%- for ? -%}
|
||||
{% while true %}
|
||||
{%
|
||||
unless if
|
||||
%}
|
||||
{% endcase %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_unclosed_liquid_tags
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% if true %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_does_not_allow_nested_docs
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% doc %}
|
||||
{% doc %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template)
|
||||
end
|
||||
|
||||
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed"
|
||||
act_error = error.message
|
||||
|
||||
assert_equal(exp_error, act_error)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_nested_raw_tags
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% raw %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_unclosed_assign
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% assign foo = "1"
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_malformed_syntax
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% {{ {%- enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_preserves_error_line_numbers
|
||||
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
|
||||
{% doc %}
|
||||
{% if true %}
|
||||
{% enddoc %}
|
||||
{{ errors.standard_error }}
|
||||
LIQUID
|
||||
|
||||
expected = <<~TEXT.chomp
|
||||
|
||||
Liquid error (line 4): standard error
|
||||
TEXT
|
||||
|
||||
assert_equal(expected, template.render('errors' => ErrorDrop.new))
|
||||
end
|
||||
|
||||
def test_doc_tag_whitespace_control
|
||||
# Basic whitespace control
|
||||
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!")
|
||||
assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!")
|
||||
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!")
|
||||
assert_template_result("Hello!", <<~LIQUID.chomp)
|
||||
{%- doc %}Whitespace control!{% enddoc -%}
|
||||
Hello!
|
||||
LIQUID
|
||||
end
|
||||
|
||||
def test_doc_tag_delimiter_handling
|
||||
assert_template_result('', <<~LIQUID.chomp)
|
||||
{% if true %}
|
||||
{% doc %}
|
||||
{% docEXTRA %}wut{% enddocEXTRA %}xyz
|
||||
{% enddoc %}
|
||||
{% endif %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
|
||||
assert_template_result('', "{% doc %}123{% enddoc\txyz %}")
|
||||
assert_template_result('', "{% doc %}123{% enddoc\nxyz %}")
|
||||
assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}")
|
||||
end
|
||||
|
||||
def test_doc_tag_visitor
|
||||
template_source = '{% doc %}{% enddoc %}'
|
||||
|
||||
assert_equal(
|
||||
[Liquid::Doc],
|
||||
visit(template_source),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def traversal(template)
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
tag_class = tag.class
|
||||
tag_class
|
||||
end
|
||||
end
|
||||
|
||||
def visit(template)
|
||||
traversal(template).visit.flatten.compact
|
||||
end
|
||||
end
|
||||
@@ -35,4 +35,15 @@ class TemplateUnitTest < Minitest::Test
|
||||
def test_template_inheritance
|
||||
assert_equal("foo", TemplateSubclass.parse("foo").render)
|
||||
end
|
||||
|
||||
def test_invalid_utf8
|
||||
input = "\xff\x00"
|
||||
error = assert_raises(SyntaxError) do
|
||||
Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input))
|
||||
end
|
||||
assert_equal(
|
||||
'Liquid syntax error: Invalid byte sequence in UTF-8',
|
||||
error.message,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class TokenizerTest < Minitest::Test
|
||||
def test_tokenize_strings
|
||||
assert_equal([' '], tokenize(' '))
|
||||
assert_equal(['hello world'], tokenize('hello world'))
|
||||
assert_equal(['{}'], tokenize('{}'))
|
||||
end
|
||||
|
||||
def test_tokenize_variables
|
||||
@@ -30,6 +31,23 @@ class TokenizerTest < Minitest::Test
|
||||
assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} "))
|
||||
end
|
||||
|
||||
def test_tokenize_with_nil_source_returns_empty_array
|
||||
assert_equal([], tokenize(nil))
|
||||
end
|
||||
|
||||
def test_incomplete_curly_braces
|
||||
assert_equal(["{{.}", " "], tokenize('{{.} '))
|
||||
assert_equal(["{{}", "%}"], tokenize('{{}%}'))
|
||||
assert_equal(["{{}}", "}"], tokenize('{{}}}'))
|
||||
end
|
||||
|
||||
def test_unmatching_start_and_end
|
||||
assert_equal(["{{%}"], tokenize('{{%}'))
|
||||
assert_equal(["{{%%%}}"], tokenize('{{%%%}}'))
|
||||
assert_equal(["{%", "}}"], tokenize('{%}}'))
|
||||
assert_equal(["{%%}", "}"], tokenize('{%%}}'))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil)
|
||||
|
||||
Reference in New Issue
Block a user