Compare commits

..
135 changed files with 1235 additions and 8423 deletions
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
+10 -48
View File
@@ -1,5 +1,5 @@
name: Liquid name: Liquid
on: [push] on: [push, pull_request]
env: env:
BUNDLE_JOBS: 4 BUNDLE_JOBS: 4
@@ -9,65 +9,27 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
entry: entry:
- { ruby: 3.3, allowed-failure: false } # minimum supported - { ruby: 2.7, allowed-failure: false } # minimum supported
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } - { ruby: 3.2, allowed-failure: false } # latest
- { ruby: 4.0, allowed-failure: false } # latest stable - { ruby: ruby-head, allowed-failure: true }
- { name: Test Ruby ${{ matrix.entry.ruby }}
ruby: 4.0,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
- { ruby: truffleruby, allowed-failure: false }
# Head can have failures due to being in development
- { ruby: head, allowed-failure: true }
- {
ruby: head,
allowed-failure: true,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: head, allowed-failure: true, rubyopt: "--yjit" }
- { ruby: head, allowed-failure: true, rubyopt: "--zjit" }
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@v3
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 - uses: ruby/setup-ruby@v1
with: with:
ruby-version: ${{ matrix.entry.ruby }} ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true bundler-cache: true
bundler: latest
- run: bundle exec rake - run: bundle exec rake
continue-on-error: ${{ matrix.entry.allowed-failure }} continue-on-error: ${{ matrix.entry.allowed-failure }}
env:
RUBYOPT: ${{ matrix.entry.rubyopt }}
spec:
runs-on: ubuntu-latest
env:
BUNDLE_WITH: spec
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
bundler-cache: true
bundler: latest
- name: Run liquid-spec for all adapters
run: |
for adapter in spec/*.rb; do
echo "=== Running $adapter ==="
bundle exec liquid-spec run "$adapter" --no-max-failures
done
memory_profile: memory_profile:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@v3
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 - uses: ruby/setup-ruby@v1
with: with:
ruby-version: 2.7
bundler-cache: true bundler-cache: true
- run: bundle exec rake memory_profile:run - run: bundle exec rake memory_profile:run
+2 -1
View File
@@ -4,6 +4,7 @@
pkg pkg
*.rbc *.rbc
.rvmrc .rvmrc
.ruby-version
Gemfile.lock
.bundle .bundle
.byebug_history .byebug_history
Gemfile.lock
+4 -8
View File
@@ -10,8 +10,8 @@ Performance:
Enabled: true Enabled: true
AllCops: AllCops:
TargetRubyVersion: 2.7
NewCops: disable NewCops: disable
SuggestExtensions: false
Exclude: Exclude:
- 'vendor/bundle/**/*' - 'vendor/bundle/**/*'
@@ -19,10 +19,6 @@ Naming/MethodName:
Exclude: Exclude:
- 'example/server/liquid_servlet.rb' - 'example/server/liquid_servlet.rb'
Style/ClassMethodsDefinitions: # Backport https://github.com/Shopify/ruby-style-guide/pull/258
Enabled: false Layout/BeginEndAlignment:
Enabled: true
# liquid filter calls were being mistaken to be calls on arrays
Style/ConcatArrayLiterals:
Exclude:
- 'test/integration/standard_filter_test.rb'
+1 -10
View File
@@ -174,16 +174,7 @@ Style/WordArray:
# Offense count: 117 # Offense count: 117
# This cop supports safe auto-correction (--auto-correct). # This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns. # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns.
# URISchemes: http, https # URISchemes: http, https
Layout/LineLength: Layout/LineLength:
Max: 260 Max: 260
Naming/PredicatePrefix:
Enabled: false
# Offense count: 1
# This is intentional - early return from begin/rescue in assignment context
Lint/NoReturnInBeginEndBlocks:
Exclude:
- 'lib/liquid/standardfilters.rb'
-1
View File
@@ -1 +0,0 @@
4.0.2
-8
View File
@@ -26,11 +26,3 @@
* If it makes sense, add tests for your code and/or run a performance benchmark * If it makes sense, add tests for your code and/or run a performance benchmark
* Make sure all tests pass (`bundle exec rake`) * Make sure all tests pass (`bundle exec rake`)
* Create a pull request * 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)
+4 -15
View File
@@ -7,32 +7,21 @@ end
gemspec gemspec
gem "base64"
group :benchmark, :test do group :benchmark, :test do
gem 'benchmark-ips' gem 'benchmark-ips'
gem 'memory_profiler' gem 'memory_profiler'
gem 'terminal-table' gem 'terminal-table'
gem "lru_redux"
install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do
gem 'stackprof' gem 'stackprof'
end end
end end
group :development do
gem "webrick"
end
group :test do group :test do
gem 'benchmark' gem 'rubocop-shopify', '~> 2.7.0', require: false
gem 'rubocop', '~> 1.82.0'
gem 'rubocop-shopify', '~> 2.18.0', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
end
group :spec do platform :mri, :truffleruby do
# Using feature branch until https://github.com/Shopify/liquid-spec/pull/144 is merged gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'master'
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'self-drop-env-lookup-specs' end
gem 'activesupport', require: false
end end
-98
View File
@@ -1,103 +1,5 @@
# Liquid Change Log # Liquid Change Log
## 5.13.0
* Add TruffleRuby in CI [Benoit Daloze]
* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze]
* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy]
* Add strict2_parse to assign and capture tags by [Alok Swamy]
* Add strict2_parse to increment and decrement tags by [Alok Swamy]
* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer]
* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro]
* Fix `SelfDrop` equality [Guilherme Carreiro]
* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer]
## 5.11.0
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
## 5.10.0
* Introduce support for Inline Snippets [Julia Boutin]
## 5.9.0
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]
## 5.8.7
* Expose body content in the `Doc` tag [James Meng]
## 5.8.1
* 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.
## 5.4.0 2022-07-29 ## 5.4.0 2022-07-29
### Breaking Changes ### Breaking Changes
+7 -93
View File
@@ -1,11 +1,11 @@
[![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml) [![Build Status](https://api.travis-ci.org/Shopify/liquid.svg?branch=master)](http://travis-ci.org/Shopify/liquid)
[![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid) [![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid)
# Liquid template engine # Liquid template engine
* [Contributing guidelines](CONTRIBUTING.md) * [Contributing guidelines](CONTRIBUTING.md)
* [Version history](History.md) * [Version history](History.md)
* [Liquid documentation from Shopify](https://shopify.dev/docs/api/liquid) * [Liquid documentation from Shopify](https://shopify.dev/api/liquid)
* [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki) * [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki)
* [Website](http://liquidmarkup.org/) * [Website](http://liquidmarkup.org/)
@@ -52,61 +52,19 @@ For standard use you can just pass it the content of a file and call render with
@template.render('name' => 'tobi') # => "hi tobi" @template.render('name' => 'tobi') # => "hi tobi"
``` ```
### Concept of Environments
In Liquid, a "Environment" is a scoped environment that encapsulates custom tags, filters, and other configurations. This allows you to define and isolate different sets of functionality for different contexts, avoiding global overrides that can lead to conflicts and unexpected behavior.
By using environments, you can:
1. **Encapsulate Logic**: Keep the logic for different parts of your application separate.
2. **Avoid Conflicts**: Prevent custom tags and filters from clashing with each other.
3. **Improve Maintainability**: Make it easier to manage and understand the scope of customizations.
4. **Enhance Security**: Limit the availability of certain tags and filters to specific contexts.
We encourage the use of Environments over globally overriding things because it promotes better software design principles such as modularity, encapsulation, and separation of concerns.
Here's an example of how you can define and use Environments in Liquid:
```ruby
user_environment = Liquid::Environment.build do |environment|
environment.register_tag("renderobj", RenderObjTag)
end
Liquid::Template.parse(<<~LIQUID, environment: user_environment)
{% renderobj src: "path/to/model.obj" %}
LIQUID
```
In this example, `RenderObjTag` is a custom tag that is only available within the `user_environment`.
Similarly, you can define another environment for a different context, such as email templates:
```ruby
email_environment = Liquid::Environment.build do |environment|
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
end
Liquid::Template.parse(<<~LIQUID, environment: email_environment)
{% unsubscribe_footer %}
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. For smaller projects, a global environment is available via `Liquid::Environment.default`.
### Error Modes ### Error Modes
Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted. Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted.
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
it very hard to debug and can lead to unexpected behaviour. it very hard to debug and can lead to unexpected behaviour.
Liquid also comes with different parsers that can be used when editing templates to give better error messages Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
when templates are invalid. You can enable this new parser like this: when templates are invalid. You can enable this new parser like this:
```ruby ```ruby
Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags Liquid::Template.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal Liquid::Template.error_mode = :lax # The default mode, accepts almost anything.
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
``` ```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
@@ -118,50 +76,6 @@ This is useful for doing things like enabling strict mode only in the theme edit
It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created. It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created.
It is also recommended that you use it in the template editors of existing apps to give editors better error messages. It is also recommended that you use it in the template editors of existing apps to give editors better error messages.
### Strict2 bindings and closures
In `error_mode: :strict2`, `let` binds a value or defines a callable Liquid body:
```liquid
{% let count = 1 %}
{% let title(step) %}
{% let count = count | plus: step %}
{{ count }}
{% endlet %}
{{ title(2) }}
```
Each call has fresh locals and writable parameters. Captures read the latest parent
bindings. `set name = expression` updates the nearest local binding, or creates a
shadow at the invocation root when the name is captured or absent. Initializers run
before installing the new binding. Loops have fresh `let` bindings each iteration
and can shadow outer names; conditional branches share their surrounding scope.
Duplicate `let` declarations in one scope
and `assign` inside closure bodies are parse errors. Calls accept positional
arguments and invoke only Liquid closures.
Custom `Liquid::Block` subclasses can use the same body rules:
```ruby
def parse(tokens)
@closure_body = parse_closure_body(tokens)
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
exports = body.invoke(context, output: output)
exports['title']&.render_to_output_buffer(context, output, ['Hello'])
output
end
```
`invoke` renders into the supplied buffer and returns direct-child body-form `let`
bindings by name. Value bindings and declarations nested in control flow are not
exported. Each invocation has fresh state; bind the body at render time rather than
caching a closure on the parsed tag. For parameterized bodies, pass
`parameters: ['name']` to `parse_closure_body`, then pass an argument array as the
second argument to `invoke`. The syntax remains visible to `ParseTreeVisitor`.
### Undefined variables and filters ### Undefined variables and filters
By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method. By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method.
@@ -197,4 +111,4 @@ template.render!({ 'x' => 1}, { strict_variables: true })
To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you. To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you.
Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns. Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns.
+4 -50
View File
@@ -33,7 +33,7 @@ task :rubocop do
end end
end end
desc('runs test suite with lax, strict, and strict2 parsers') desc('runs test suite with both strict and lax parsers')
task :test do task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax' ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke Rake::Task['base_test'].invoke
@@ -42,11 +42,9 @@ task :test do
Rake::Task['base_test'].reenable Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke Rake::Task['base_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby' if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_C'] = '1'
ENV['LIQUID_PARSER_MODE'] = 'lax' ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke Rake::Task['integration_test'].invoke
@@ -54,10 +52,6 @@ task :test do
ENV['LIQUID_PARSER_MODE'] = 'strict' ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end end
end end
@@ -79,7 +73,7 @@ end
namespace :benchmark do namespace :benchmark do
desc "Run the liquid benchmark with lax parsing" desc "Run the liquid benchmark with lax parsing"
task :lax do task :run do
ruby "./performance/benchmark.rb lax" ruby "./performance/benchmark.rb lax"
end end
@@ -87,38 +81,6 @@ namespace :benchmark do
task :strict do task :strict do
ruby "./performance/benchmark.rb strict" ruby "./performance/benchmark.rb strict"
end end
desc "Run the liquid benchmark with strict2 parsing"
task :strict2 do
ruby "./performance/benchmark.rb strict2"
end
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
task run: [:lax, :strict, :strict2]
desc "Run unit benchmarks"
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 end
namespace :profile do namespace :profile do
@@ -148,11 +110,3 @@ end
task :console do task :console do
exec 'irb -I lib -r liquid' exec 'irb -I lib -r liquid'
end end
desc('run liquid-spec suite across all adapters')
task :spec do
Dir['./spec/*.rb'].sort.each do |adapter|
puts "=== Running #{adapter} ==="
sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures'
end
end
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'bundler/setup'
require 'liquid'
class VirtualFileSystem
def initialize
snippet_1 = <<~LIQUID
<h1>
{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
</h1>
LIQUID
snippet_2 = <<~LIQUID
{%- for i in (1..5) -%}
> {{ i }}
{%- endfor -%}
LIQUID
@templates = {
'snippet-1' => snippet_1,
'snippet-2' => snippet_2,
}
end
def read_template_file(key)
@templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'")
end
end
def source
File.read(ARGV[0])
rescue StandardError
'Usage: bin/render example/server/templates/index.liquid'
end
def assigns
{
'date' => Time.now,
}
end
puts Liquid::Template
.parse(source, error_mode: :strict2)
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
.render(assigns)
+5 -9
View File
@@ -30,18 +30,14 @@ class Servlet < LiquidServlet
private private
def products_list def products_list
[ [{ 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' },
{ 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, { 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' },
{ 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' }, { 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }]
{ 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }
]
end end
def more_products_list def more_products_list
[ [{ 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' },
{ 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' }, { 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' }]
{ 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' }
]
end end
def description def description
+16 -19
View File
@@ -21,8 +21,6 @@
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
require "strscan"
module Liquid module Liquid
FilterSeparator = /\|/ FilterSeparator = /\|/
ArgumentSeparator = ',' ArgumentSeparator = ','
@@ -43,48 +41,43 @@ module Liquid
AnyStartingTag = /#{TagStart}|#{VariableStart}/o AnyStartingTag = /#{TagStart}|#{VariableStart}/o
PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om
TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
singleton_class.send(:attr_accessor, :cache_classes)
self.cache_classes = true
end end
require "liquid/version" require "liquid/version"
require "liquid/deprecations"
require "liquid/const"
require 'liquid/standardfilters'
require 'liquid/file_system'
require 'liquid/parser_switching'
require 'liquid/tag'
require 'liquid/block'
require 'liquid/parse_tree_visitor' require 'liquid/parse_tree_visitor'
require 'liquid/closure_body'
require 'liquid/closure'
require 'liquid/call_expression'
require 'liquid/interrupts'
require 'liquid/tags'
require "liquid/environment"
require 'liquid/lexer' require 'liquid/lexer'
require 'liquid/parser' require 'liquid/parser'
require 'liquid/i18n' require 'liquid/i18n'
require 'liquid/drop' require 'liquid/drop'
require 'liquid/self_drop'
require 'liquid/tablerowloop_drop' require 'liquid/tablerowloop_drop'
require 'liquid/forloop_drop' require 'liquid/forloop_drop'
require 'liquid/extensions' require 'liquid/extensions'
require 'liquid/errors' require 'liquid/errors'
require 'liquid/interrupts' require 'liquid/interrupts'
require 'liquid/strainer_template' require 'liquid/strainer_template'
require 'liquid/strainer_factory'
require 'liquid/expression'
require 'liquid/context' require 'liquid/context'
require 'liquid/parser_switching'
require 'liquid/tag' require 'liquid/tag'
require 'liquid/tag/disabler'
require 'liquid/tag/disableable'
require 'liquid/block'
require 'liquid/block_body' require 'liquid/block_body'
require 'liquid/document' require 'liquid/document'
require 'liquid/variable' require 'liquid/variable'
require 'liquid/variable_lookup' require 'liquid/variable_lookup'
require 'liquid/range_lookup' require 'liquid/range_lookup'
require 'liquid/file_system'
require 'liquid/resource_limits' require 'liquid/resource_limits'
require 'liquid/expression'
require 'liquid/template' require 'liquid/template'
require 'liquid/standardfilters'
require 'liquid/condition' require 'liquid/condition'
require 'liquid/utils' require 'liquid/utils'
require 'liquid/tokenizer' require 'liquid/tokenizer'
@@ -93,3 +86,7 @@ require 'liquid/partial_cache'
require 'liquid/usage' require 'liquid/usage'
require 'liquid/registers' require 'liquid/registers'
require 'liquid/template_factory' require 'liquid/template_factory'
# Load all the tags of the standard library
#
Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f }
+8 -22
View File
@@ -10,13 +10,10 @@ module Liquid
end end
def parse(tokens) def parse(tokens)
parse_block_body(tokens) @body = new_body
end while parse_body(@body, tokens)
end
# Parse an external block body with closure validation and lexical declarations. @body.freeze
def parse_closure_body(tokens, parameters: Const::EMPTY_ARRAY)
parse_context.with_closure(parameters) { parse_block_body(tokens) }
ClosureBody.new(@body, parameters)
end end
# For backwards compatibility # For backwards compatibility
@@ -39,17 +36,13 @@ module Liquid
# @api private # @api private
def self.raise_unknown_tag(tag, block_name, block_delimiter, parse_context) def self.raise_unknown_tag(tag, block_name, block_delimiter, parse_context)
if tag == 'else' if tag == 'else'
raise SyntaxError, parse_context.locale.t( raise SyntaxError, parse_context.locale.t("errors.syntax.unexpected_else",
"errors.syntax.unexpected_else", block_name: block_name)
block_name: block_name,
)
elsif tag.start_with?('end') elsif tag.start_with?('end')
raise SyntaxError, parse_context.locale.t( raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_delimiter",
"errors.syntax.invalid_delimiter",
tag: tag, tag: tag,
block_name: block_name, block_name: block_name,
block_delimiter: block_delimiter, block_delimiter: block_delimiter)
)
else else
raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag) raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag)
end end
@@ -69,13 +62,6 @@ module Liquid
private private
def parse_block_body(tokens)
@body = new_body
while parse_body(@body, tokens)
end
@body.freeze
end
# @api public # @api public
def new_body def new_body
parse_context.new_block_body parse_context.new_block_body
+9 -21
View File
@@ -6,7 +6,6 @@ module Liquid
class BlockBody class BlockBody
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om
FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om
ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om
WhitespaceOrNothing = /\A\s*\z/ WhitespaceOrNothing = /\A\s*\z/
TAGSTART = "{%" TAGSTART = "{%"
@@ -46,13 +45,7 @@ module Liquid
end end
tag_name = Regexp.last_match(1) tag_name = Regexp.last_match(1)
markup = Regexp.last_match(2) markup = Regexp.last_match(2)
unless (tag = registered_tags[tag_name])
if tag_name == 'liquid'
parse_context.line_number -= 1
next parse_liquid_tag(markup, parse_context)
end
unless (tag = parse_context.environment.tag_for_name(tag_name))
# end parsing if we reach an unknown tag and let the caller decide # end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed # determine how to proceed
return yield tag_name, markup return yield tag_name, markup
@@ -99,9 +92,7 @@ module Liquid
context.handle_error(exc, line_number) context.handle_error(exc, line_number)
else else
error_message = context.handle_error(exc, line_number) error_message = context.handle_error(exc, line_number)
error_mode = context.registers.static[:template_error_mode] unless blank_tag # conditional for backwards compatibility
suppress_error_text = blank_tag && error_mode != :strict2 && error_mode != :rigid
unless suppress_error_text # blank-tag suppression is kept for backwards compatibility outside strict2
output << error_message output << error_message
end end
end end
@@ -149,7 +140,7 @@ module Liquid
next next
end end
unless (tag = parse_context.environment.tag_for_name(tag_name)) unless (tag = registered_tags[tag_name])
# end parsing if we reach an unknown tag and let the caller decide # end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed # determine how to proceed
return yield tag_name, markup return yield tag_name, markup
@@ -248,17 +239,10 @@ module Liquid
end end
def create_variable(token, parse_context) def create_variable(token, parse_context)
if token.end_with?("}}") if token =~ ContentOfVariable
i = 2 markup = Regexp.last_match(1)
i = 3 if token[i] == "-"
parse_end = token.length - 3
parse_end -= 1 if token[parse_end] == "-"
markup_end = parse_end - i + 1
markup = markup_end <= 0 ? "" : token.slice(i, markup_end)
return Variable.new(markup, parse_context) return Variable.new(markup, parse_context)
end end
BlockBody.raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context)
end end
@@ -271,5 +255,9 @@ module Liquid
def raise_missing_variable_terminator(token, parse_context) def raise_missing_variable_terminator(token, parse_context)
BlockBody.raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context)
end end
def registered_tags
Template.tags
end
end end
end end
-33
View File
@@ -1,33 +0,0 @@
# frozen_string_literal: true
module Liquid
class CallExpression
attr_reader :target, :arguments
def initialize(target, arguments)
@target = target.freeze
@arguments = arguments.freeze
freeze
end
def evaluate(context)
context.resource_limits.with_capture do
render_to_output_buffer(context, +'')
end
end
def render_to_output_buffer(context, output)
closure = context.evaluate(@target)
raise Liquid::ArgumentError, "Expected a Liquid closure" unless closure.is_a?(Closure)
arguments = @arguments.map { |argument| context.evaluate(argument) }
closure.render_to_output_buffer(context, output, arguments)
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.target] + @node.arguments
end
end
end
end
-96
View File
@@ -1,96 +0,0 @@
# frozen_string_literal: true
module Liquid
# A render-time value. Captures retain binding hashes, not copies of their values.
class Closure
def initialize(body, parameters, context)
@body = body
@parameters = parameters
@scopes = context.scopes.dup
@environments = context.environments.dup
@static_environments = context.static_environments
end
# Render into the supplied buffer and return direct-child body-form let bindings.
def invoke(context, arguments = Const::EMPTY_ARRAY, output: +'')
invocation = render_invocation(context, arguments, output)
@body.exports(invocation)
end
def render_to_output_buffer(context, output, arguments = Const::EMPTY_ARRAY)
render_invocation(context, arguments, output)
output
end
def to_liquid
self
end
def to_s
''
end
private
def render_invocation(context, arguments, output)
unless arguments.length == @parameters.length
raise Liquid::ArgumentError, "Expected #{@parameters.length} closure arguments, got #{arguments.length}"
end
invocation = context.new_closure_subcontext(@scopes, @environments, @static_environments)
@parameters.each_with_index { |name, index| invocation[name] = arguments[index] }
@body.render_to_output_buffer(invocation, output)
invocation
end
# @api private
class CapturedBindings
def initialize(scopes, environments, context)
@scopes = scopes
@environments = environments
@context = context
@locals = {}
end
def key?(name)
local_key?(name) || @environments.any? { |environment| environment.key?(name) }
end
def local_key?(name)
@locals.key?(name) || @scopes.any? { |scope| scope.key?(name) } ||
@environments.any? { |environment| environment.is_a?(CapturedBindings) && environment.local_key?(name) }
end
def []=(name, value)
@locals[name] = value
end
def [](name)
find_variable(name, @context, raise_on_not_found: false)
end
def find_variable(name, context, raise_on_not_found: true)
return context.lookup_and_evaluate(@locals, name, raise_on_not_found: raise_on_not_found) if @locals.key?(name)
scope = @scopes.find { |candidate| candidate.key?(name) }
return context.lookup_and_evaluate(scope, name, raise_on_not_found: raise_on_not_found) if scope
find_in_environments(name, context, raise_on_not_found: raise_on_not_found)
end
private
def find_in_environments(name, context, raise_on_not_found:)
@environments.each do |environment|
if environment.is_a?(CapturedBindings)
return environment.find_variable(name, context, raise_on_not_found: raise_on_not_found)
end
value = context.lookup_and_evaluate(environment, name, raise_on_not_found: raise_on_not_found)
return value if !value.nil? || context.strict_variables && raise_on_not_found
end
nil
end
end
end
end
-31
View File
@@ -1,31 +0,0 @@
# frozen_string_literal: true
module Liquid
# Parsed syntax shared by let and closure-aware custom blocks.
class ClosureBody
attr_reader :body
def initialize(body, parameters = Const::EMPTY_ARRAY)
@body = body
@parameters = parameters.dup.freeze
@exports = body.nodelist.select { |node| node.is_a?(Let) && node.body_form? }.map(&:to).freeze
freeze
end
def bind(context)
Closure.new(self, @parameters, context)
end
def render_to_output_buffer(context, output)
@body.render_to_output_buffer(context, output)
end
def exports(context)
context.scopes.last.slice(*@exports)
end
def nodelist
@body.nodelist
end
end
end
+18 -66
View File
@@ -24,9 +24,6 @@ module Liquid
else else
false false
end end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end, end,
} }
@@ -48,8 +45,8 @@ module Liquid
@@operators @@operators
end end
def self.parse_expression(parse_context, markup, safe: false) def self.parse_expression(parse_context, markup)
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe) @@method_literals[markup] || parse_context.parse_expression(markup)
end end
attr_reader :attachment, :child_condition attr_reader :attachment, :child_condition
@@ -72,9 +69,9 @@ module Liquid
case condition.child_relation case condition.child_relation
when :or when :or
break if Liquid::Utils.to_liquid_value(result) break if result
when :and when :and
break unless Liquid::Utils.to_liquid_value(result) break unless result
else else
break break
end end
@@ -113,67 +110,24 @@ module Liquid
def equal_variables(left, right) def equal_variables(left, right)
if left.is_a?(MethodLiteral) if left.is_a?(MethodLiteral)
return call_method_literal(left, right) if right.respond_to?(left.method_name)
return right.send(left.method_name)
else
return nil
end
end end
if right.is_a?(MethodLiteral) if right.is_a?(MethodLiteral)
return call_method_literal(right, left) if left.respond_to?(right.method_name)
return left.send(right.method_name)
else
return nil
end
end end
left == right left == right
end end
def call_method_literal(literal, value)
method_name = literal.method_name
# If the object responds to the method (e.g., ActiveSupport is loaded), use it
if value.respond_to?(method_name)
value.send(method_name)
else
# Emulate ActiveSupport's blank?/empty? to make Liquid invariant
# to whether ActiveSupport is loaded or not
case method_name
when :blank?
liquid_blank?(value)
when :empty?
liquid_empty?(value)
else
false
end
end
end
# Implement blank? semantics matching ActiveSupport
# blank? returns true for nil, false, empty strings, whitespace-only strings,
# empty arrays, and empty hashes
def liquid_blank?(value)
case value
when NilClass, FalseClass
true
when TrueClass, Numeric
false
when String
# Blank if empty or whitespace only (matches ActiveSupport)
value.empty? || value.match?(/\A\s*\z/)
when Array, Hash
value.empty?
else
# Fall back to empty? if available, otherwise false
value.respond_to?(:empty?) ? value.empty? : false
end
end
# Implement empty? semantics
# Note: nil is NOT empty. empty? checks if a collection has zero elements.
def liquid_empty?(value)
case value
when String, Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : false
end
end
def interpret_condition(left, right, op, context) def interpret_condition(left, right, op, context)
# If the operator is empty this means that the decision statement is just # If the operator is empty this means that the decision statement is just
# a single variable. We can just poll this variable from the context and # a single variable. We can just poll this variable from the context and
@@ -197,18 +151,16 @@ module Liquid
end end
def deprecated_default_context def deprecated_default_context
warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \ warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
"and will be removed from Liquid 6.0.0.") " and will be removed from Liquid 6.0.0.")
Context.new Context.new
end end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children def children
[ [
@node.left, @node.left, @node.right,
@node.right, @node.child_condition, @node.attachment
@node.child_condition,
@node.attachment
].compact ].compact
end end
end end
-8
View File
@@ -1,8 +0,0 @@
# frozen_string_literal: true
module Liquid
module Const
EMPTY_HASH = {}.freeze
EMPTY_ARRAY = [].freeze
end
end
+16 -82
View File
@@ -15,40 +15,35 @@ module Liquid
# context['bob'] #=> nil class Context # context['bob'] #=> nil class Context
class Context class Context
attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :environment attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters
# rubocop:disable Metrics/ParameterLists # rubocop:disable Metrics/ParameterLists
def self.build(environment: Environment.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block)
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, environment, &block) new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, &block)
end end
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default) def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {})
@environment = environment
@environments = [environments] @environments = [environments]
@environments.flatten! @environments.flatten!
@static_environments = [static_environments].flatten(1).freeze @static_environments = [static_environments].flatten(1).freeze
@scopes = [outer_scope || {}] @scopes = [(outer_scope || {})]
@registers = registers.is_a?(Registers) ? registers : Registers.new(registers) @registers = registers.is_a?(Registers) ? registers : Registers.new(registers)
@errors = [] @errors = []
@partial = false @partial = false
@strict_variables = false @strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits) @resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
@base_scope_depth = 0 @base_scope_depth = 0
@interrupts = [] @interrupts = []
@filters = [] @filters = []
@global_filter = nil @global_filter = nil
@disabled_tags = {} @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[:cached_partials] ||= {}
@registers.static[:file_system] ||= environment.file_system @registers.static[:file_system] ||= Liquid::Template.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new @registers.static[:template_factory] ||= Liquid::TemplateFactory.new
self.exception_renderer = environment.exception_renderer self.exception_renderer = Template.default_exception_renderer
if rethrow_errors if rethrow_errors
self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA
end end
@@ -65,7 +60,7 @@ module Liquid
end end
def strainer def strainer
@strainer ||= @environment.create_strainer(self, @filters) @strainer ||= StrainerFactory.create(self, @filters)
end end
# Adds filters to this context. # Adds filters to this context.
@@ -147,10 +142,9 @@ module Liquid
check_overflow check_overflow
self.class.build( self.class.build(
environment: @environment,
resource_limits: resource_limits, resource_limits: resource_limits,
static_environments: static_environments, static_environments: static_environments,
registers: Registers.new(registers), registers: Registers.new(registers)
).tap do |subcontext| ).tap do |subcontext|
subcontext.base_scope_depth = base_scope_depth + 1 subcontext.base_scope_depth = base_scope_depth + 1
subcontext.exception_renderer = exception_renderer subcontext.exception_renderer = exception_renderer
@@ -162,40 +156,6 @@ module Liquid
end end
end end
# @api private
def new_closure_subcontext(scopes, environments, static_environments)
new_isolated_subcontext.tap do |invocation|
captures = Closure::CapturedBindings.new(scopes, environments + static_environments, invocation)
invocation.captured_bindings = captures
invocation.environments = [captures]
invocation.static_environments = static_environments
invocation.strict_variables = strict_variables
invocation.strict_filters = strict_filters
invocation.global_filter = global_filter
invocation.template_name = template_name
invocation.partial = partial
invocation.base_scope_depth = base_scope_depth + @scopes.length
end
end
# @api private
def with_binding_scope
previous_root = @binding_root
stack do
@binding_root = @scopes.first
yield
end
ensure
@binding_root = previous_root
end
# @api private
def set_local(name, value)
root = @binding_root || @scopes.last
scope = @scopes.find { |owned| owned.key?(name) || owned.equal?(root) }
scope[name] = value
end
def clear_instance_assigns def clear_instance_assigns
@scopes[0] = {} @scopes[0] = {}
end end
@@ -214,20 +174,11 @@ module Liquid
# Example: # Example:
# products == empty #=> products.empty? # products == empty #=> products.empty?
def [](expression) def [](expression)
evaluate(Expression.parse(expression, @string_scanner)) evaluate(Expression.parse(expression))
end end
def key?(key) def key?(key)
find_variable(key, raise_on_not_found: false) != nil self[key] != nil
end
# Checks whether a variable is defined in any scope, including nil-valued keys.
# Unlike #key?, this uses Hash#key? so that variables explicitly set to nil
# are still considered defined.
def variable_defined?(key)
@scopes.any? { |s| s.key?(key) } ||
@environments.any? { |e| e.key?(key) } ||
@static_environments.any? { |e| e.key?(key) }
end end
def evaluate(object) def evaluate(object)
@@ -240,29 +191,16 @@ module Liquid
# path and find_index() is optimized in MRI to reduce object allocation # path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) } index = @scopes.find_index { |s| s.key?(key) }
fallback_to_self_drop = key == Expression::SELF && index.nil? && !@captured_bindings&.local_key?(key)
variable = if index variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found) lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else else
try_variable_find_in_environments( try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
key,
raise_on_not_found: raise_on_not_found && !fallback_to_self_drop,
)
end end
# `self` resolves to a SelfDrop (enabling `self['var']` lookups), variable = variable.to_liquid
# but only after the normal environment lookup doesn't find a value.
return @self_drop ||= SelfDrop.new(self) if fallback_to_self_drop && variable.nil?
# update variable's context before invoking #to_liquid
variable.context = self if variable.respond_to?(:context=) variable.context = self if variable.respond_to?(:context=)
liquid_variable = variable.to_liquid variable
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
liquid_variable
end end
def lookup_and_evaluate(obj, key, raise_on_not_found: true) def lookup_and_evaluate(obj, key, raise_on_not_found: true)
@@ -296,17 +234,13 @@ module Liquid
protected protected
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags, :environments, :static_environments, :captured_bindings attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags
private private
attr_reader :base_scope_depth attr_reader :base_scope_depth
def try_variable_find_in_environments(key, raise_on_not_found:) def try_variable_find_in_environments(key, raise_on_not_found:)
if @captured_bindings
return @captured_bindings.find_variable(key, self, raise_on_not_found: raise_on_not_found)
end
@environments.each do |environment| @environments.each do |environment|
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found) found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
if !found_variable.nil? || @strict_variables && raise_on_not_found if !found_variable.nil? || @strict_variables && raise_on_not_found
-22
View File
@@ -1,22 +0,0 @@
# frozen_string_literal: true
require "set"
module Liquid
class Deprecations
class << self
attr_accessor :warned
Deprecations.warned = Set.new
def warn(name, alternative)
return if warned.include?(name)
warned << name
caller_location = caller_locations(2, 1).first
Warning.warn("[DEPRECATION] #{name} is deprecated. Use #{alternative} instead. Called from #{caller_location}\n")
end
end
end
end
+2 -6
View File
@@ -20,9 +20,7 @@ module Liquid
end end
def parse(tokenizer, parse_context) def parse(tokenizer, parse_context)
@local_bindings = parse_context.with_document_scope do while parse_body(tokenizer)
while parse_body(tokenizer)
end
end end
@body.freeze @body.freeze
rescue SyntaxError => e rescue SyntaxError => e
@@ -40,9 +38,7 @@ module Liquid
end end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
return @body.render_to_output_buffer(context, output) unless @local_bindings @body.render_to_output_buffer(context, output)
context.with_binding_scope { @body.render_to_output_buffer(context, output) }
end end
def render(context) def render(context)
+1 -1
View File
@@ -31,7 +31,7 @@ module Liquid
# Catch all for the method # Catch all for the method
def liquid_method_missing(method) def liquid_method_missing(method)
return unless @context&.strict_variables return nil unless @context&.strict_variables
raise Liquid::UndefinedDropMethod, "undefined method #{method}" raise Liquid::UndefinedDropMethod, "undefined method #{method}"
end end
-159
View File
@@ -1,159 +0,0 @@
# frozen_string_literal: true
module Liquid
# The Environment is the container for all configuration options of Liquid, such as
# the registered tags, filters, and the default error mode.
class Environment
# The default error mode for all templates. This can be overridden on a
# per-template basis.
attr_accessor :error_mode
# The tags that are available to use in the template.
attr_accessor :tags
# The strainer template which is used to store filters that are available to
# use in templates.
attr_accessor :strainer_template
# The exception renderer that is used to render exceptions that are raised
# when rendering a template
attr_accessor :exception_renderer
# The default file system that is used to load templates from.
attr_accessor :file_system
# The default resource limits that are used to limit the resources that a
# template can consume.
attr_accessor :default_resource_limits
class << self
# Creates a new environment instance.
#
# @param tags [Hash] The tags that are available to use in
# the template.
# @param file_system The default file system that is used
# to load templates from.
# @param error_mode [Symbol] The default error mode for all templates
# (either :strict2, :strict, :warn, or :lax).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
ret = new
ret.tags = tags if tags
ret.file_system = file_system if file_system
ret.error_mode = error_mode if error_mode
ret.exception_renderer = exception_renderer if exception_renderer
yield ret if block_given?
ret.freeze
end
# Returns the default environment instance.
#
# @return [Environment] The default environment instance.
def default
@default ||= new
end
# Sets the default environment instance for the duration of the block
#
# @param environment [Environment] The environment instance to use as the default for the
# duration of the block.
# @yield
# @return [Object] The return value of the block.
def dangerously_override(environment)
original_default = @default
@default = environment
yield
ensure
@default = original_default
end
end
# Initializes a new environment instance.
# @api private
def initialize
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
end
@exception_renderer = ->(exception) { exception }
@file_system = BlankFileSystem.new
@default_resource_limits = Const::EMPTY_HASH
@strainer_template_class_cache = {}
end
# Registers a new tag with the environment.
#
# @param name [String] The name of the tag.
# @param klass [Liquid::Tag] The class that implements the tag.
# @return [void]
def register_tag(name, klass)
@tags[name] = klass
end
# Registers a new filter with the environment.
#
# @param filter [Module] The module that contains the filter methods.
# @return [void]
def register_filter(filter)
@strainer_template_class_cache.clear
@strainer_template.add_filter(filter)
end
# Registers multiple filters with this environment.
#
# @param filters [Array<Module>] The modules that contain the filter methods.
# @return [self]
def register_filters(filters)
@strainer_template_class_cache.clear
filters.each { |f| @strainer_template.add_filter(f) }
self
end
# Creates a new strainer instance with the given filters, caching the result
# for faster lookup.
#
# @param context [Liquid::Context] The context that the strainer will be
# used in.
# @param filters [Array<Module>] The filters that the strainer will have
# access to.
# @return [Liquid::Strainer] The new strainer instance.
def create_strainer(context, filters = Const::EMPTY_ARRAY)
return @strainer_template.new(context) if filters.empty?
strainer_template = @strainer_template_class_cache[filters] ||= begin
klass = Class.new(@strainer_template)
filters.each { |f| klass.add_filter(f) }
klass
end
strainer_template.new(context)
end
# Returns the names of all the filter methods that are available to use in
# the strainer template.
#
# @return [Array<String>] The names of all the filter methods.
def filter_method_names
@strainer_template.filter_method_names
end
# Returns the tag class for the given tag name.
#
# @param name [String] The name of the tag.
# @return [Liquid::Tag] The tag class.
def tag_for_name(name)
@tags[name]
end
def freeze
@tags.freeze
# TODO: freeze the tags, currently this is not possible because of liquid-c
# @strainer_template.freeze
super
end
end
end
+15 -16
View File
@@ -40,20 +40,19 @@ module Liquid
end end
end end
ArgumentError = Class.new(Error) ArgumentError = Class.new(Error)
ContextError = Class.new(Error) ContextError = Class.new(Error)
FileSystemError = Class.new(Error) FileSystemError = Class.new(Error)
StandardError = Class.new(Error) StandardError = Class.new(Error)
SyntaxError = Class.new(Error) SyntaxError = Class.new(Error)
StackLevelError = Class.new(Error) StackLevelError = Class.new(Error)
MemoryError = Class.new(Error) MemoryError = Class.new(Error)
ZeroDivisionError = Class.new(Error) ZeroDivisionError = Class.new(Error)
FloatDomainError = Class.new(Error) FloatDomainError = Class.new(Error)
UndefinedVariable = Class.new(Error) UndefinedVariable = Class.new(Error)
UndefinedDropMethod = Class.new(Error) UndefinedDropMethod = Class.new(Error)
UndefinedFilter = Class.new(Error) UndefinedFilter = Class.new(Error)
MethodOverrideError = Class.new(Error) MethodOverrideError = Class.new(Error)
DisabledError = Class.new(Error) DisabledError = Class.new(Error)
InternalError = Class.new(Error) InternalError = Class.new(Error)
TemplateEncodingError = Class.new(Error)
end end
+23 -109
View File
@@ -2,128 +2,42 @@
module Liquid module Liquid
class Expression class Expression
SELF = 'self'
LITERALS = { LITERALS = {
nil => nil, nil => nil, 'nil' => nil, 'null' => nil, '' => nil,
'nil' => nil,
'null' => nil,
'' => nil,
'true' => true, 'true' => true,
'false' => false, 'false' => false,
'blank' => '', '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 }.freeze
DOT = ".".ord INTEGERS_REGEX = /\A(-?\d+)\z/
ZERO = "0".ord FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
NINE = "9".ord
DASH = "-".ord
# Use an atomic group (?>...) to avoid pathological backtracing from # Use an atomic group (?>...) to avoid pathological backtracing from
# malicious input as described in https://github.com/Shopify/liquid/issues/1357 # 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/
class << self def self.parse(markup)
def safe_parse(parser, ss = StringScanner.new(""), cache = nil) return nil unless markup
parse(parser.expression, ss, cache)
markup = markup.strip
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
end end
def parse(markup, ss = StringScanner.new(""), cache = nil) case markup
return unless markup when INTEGERS_REGEX
return markup unless markup.is_a?(String) Regexp.last_match(1).to_i
when RANGES_REGEX
markup = markup.strip # markup can be a frozen string RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2))
when FLOATS_REGEX
if (markup.start_with?('"') && markup.end_with?('"')) || Regexp.last_match(1).to_f
(markup.start_with?("'") && markup.end_with?("'")) else
return markup[1..-2] if LITERALS.key?(markup)
elsif LITERALS.key?(markup) LITERALS[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 else
inner_parse(markup, ss, nil).freeze VariableLookup.parse(markup)
end
end
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, 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 end
end end
+1 -1
View File
@@ -5,7 +5,7 @@ module Liquid
# @liquid_type object # @liquid_type object
# @liquid_name forloop # @liquid_name forloop
# @liquid_summary # @liquid_summary
# Information about a parent [`for` loop](/docs/api/liquid/tags/for). # Information about a parent [`for` loop](/api/liquid/tags/for).
class ForloopDrop < Drop class ForloopDrop < Drop
def initialize(name, length, parentloop) def initialize(name, length, parentloop)
@name = name @name = name
+1 -1
View File
@@ -28,7 +28,7 @@ module Liquid
def interpolate(name, vars) def interpolate(name, vars)
name.gsub(/%\{(\w+)\}/) do name.gsub(/%\{(\w+)\}/) do
# raise TranslationError, "Undefined key #{$1} for interpolation in translation #{name}" unless vars[$1.to_sym] # raise TranslationError, "Undefined key #{$1} for interpolation in translation #{name}" unless vars[$1.to_sym]
vars[Regexp.last_match(1).to_sym].to_s (vars[Regexp.last_match(1).to_sym]).to_s
end end
end end
+42 -163
View File
@@ -1,182 +1,61 @@
# frozen_string_literal: true # frozen_string_literal: true
require "strscan"
module Liquid module Liquid
class Lexer class Lexer
ASSIGN = [:assign, "="].freeze SPECIALS = {
CLOSE_ROUND = [:close_round, ")"].freeze '|' => :pipe,
CLOSE_SQUARE = [:close_square, "]"].freeze '.' => :dot,
COLON = [:colon, ":"].freeze ':' => :colon,
COMMA = [:comma, ","].freeze ',' => :comma,
COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze '[' => :open_square,
COMPARISON_CONTAINS = [:comparison, "contains"].freeze ']' => :close_square,
COMPARISON_EQUAL = [:comparison, "=="].freeze '(' => :open_round,
COMPARISON_GREATER_THAN = [:comparison, ">"].freeze ')' => :close_round,
COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze '?' => :question,
COMPARISON_LESS_THAN = [:comparison, "<"].freeze '-' => :dash,
COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze }.freeze
COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
DASH = [:dash, "-"].freeze
DOT = [:dot, "."].freeze
DOTDOT = [:dotdot, ".."].freeze
DOT_ORD = ".".ord
DOUBLE_STRING_LITERAL = /"[^\"]*"/
EOS = [:end_of_string].freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/ IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
OPEN_ROUND = [:open_round, "("].freeze
OPEN_SQUARE = [:open_square, "["].freeze
PIPE = [:pipe, "|"].freeze
QUESTION = [:question, "?"].freeze
RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze
SINGLE_STRING_LITERAL = /'[^\']*'/ 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*/ WHITESPACE_OR_NOTHING = /\s*/
SINGLE_COMPARISON_TOKENS = [].tap do |table| def initialize(input)
table["<".ord] = COMPARISON_LESS_THAN @ss = StringScanner.new(input)
table[">".ord] = COMPARISON_GREATER_THAN
table.freeze
end end
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table| def tokenize
table["=".ord] = [].tap do |sub_table| @output = []
sub_table["=".ord] = COMPARISON_EQUAL
sub_table.freeze
end
table["!".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table.freeze
end
table.freeze
end
COMPARISON_JUMP_TABLE = [].tap do |table| until @ss.eos?
table["<".ord] = [].tap do |sub_table| @ss.skip(WHITESPACE_OR_NOTHING)
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL break if @ss.eos?
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT tok = if (t = @ss.scan(COMPARISON_OPERATOR))
sub_table.freeze [:comparison, t]
end elsif (t = @ss.scan(STRING_LITERAL))
table[">".ord] = [].tap do |sub_table| [:string, t]
sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL elsif (t = @ss.scan(NUMBER_LITERAL))
sub_table.freeze [:number, t]
end elsif (t = @ss.scan(IDENTIFIER))
table.freeze [:id, t]
end elsif (t = @ss.scan(DOTDOT))
[:dotdot, t]
NEXT_MATCHER_JUMP_TABLE = [].tap do |table| else
"a".upto("z") do |c| c = @ss.getch
table[c.ord] = [:id, IDENTIFIER].freeze if (s = SPECIALS[c])
table[c.upcase.ord] = [:id, IDENTIFIER].freeze [s, c]
end
table["_".ord] = [:id, IDENTIFIER].freeze
"0".upto("9") do |c|
table[c.ord] = [:number, NUMBER_LITERAL].freeze
end
table["-".ord] = [:number, NUMBER_LITERAL].freeze
table["'".ord] = [:string, SINGLE_STRING_LITERAL].freeze
table["\"".ord] = [:string, DOUBLE_STRING_LITERAL].freeze
table.freeze
end
SPECIAL_TABLE = [].tap do |table|
table["|".ord] = PIPE
table[".".ord] = DOT
table[":".ord] = COLON
table[",".ord] = COMMA
table["[".ord] = OPEN_SQUARE
table["]".ord] = CLOSE_SQUARE
table["(".ord] = OPEN_ROUND
table[")".ord] = CLOSE_ROUND
table["?".ord] = QUESTION
table["-".ord] = DASH
end
NUMBER_TABLE = [].tap do |table|
"0".upto("9") do |c|
table[c.ord] = true
end
table.freeze
end
# rubocop:disable Metrics/BlockNesting
class << self
def tokenize(ss)
output = []
until ss.eos?
ss.skip(WHITESPACE_OR_NOTHING)
break if ss.eos?
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)]
else
output << special
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
elsif peeked == "=".ord && ss.peek_byte != "<".ord && ss.peek_byte != ">".ord && ss.peek_byte != "!".ord
output << ASSIGN
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 else
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] raise SyntaxError, "Unexpected character #{c}"
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
raise_syntax_error(start_pos, ss)
end
end end
end end
# rubocop:enable Metrics/BlockNesting @output << tok
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 end
def raise_syntax_error(start_pos, ss) @output << [:end_of_string]
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
end end
end end
-4
View File
@@ -2,14 +2,12 @@
errors: errors:
syntax: syntax:
tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}" 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]" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
case: "Syntax Error in 'case' - Valid syntax: case [condition]" 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_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) " 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 ...]" 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: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"
for_invalid_in: "For loops require an 'in' clause" for_invalid_in: "For loops require an 'in' clause"
for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset" for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset"
@@ -17,10 +15,8 @@
include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]" include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]"
inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character" inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character"
invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}"
invalid_template_encoding: "Invalid template encoding"
render: "Syntax error in tag 'render' - Template name must be a quoted string" render: "Syntax error in tag 'render' - Template name must be a quoted string"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
tag_never_closed: "'%{block_name}' tag was never closed" tag_never_closed: "'%{block_name}' tag was never closed"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}" tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
unexpected_else: "%{block_name} tag does not expect 'else' tag" unexpected_else: "%{block_name} tag does not expect 'else' tag"
+7 -97
View File
@@ -3,27 +3,14 @@
module Liquid module Liquid
class ParseContext class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode, :environment attr_reader :partial, :warnings, :error_mode
def initialize(options = Const::EMPTY_HASH) def initialize(options = {})
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {} @template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new @locale = @template_options[:locale] ||= I18n.new
@warnings = [] @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.depth = 0
self.partial = false self.partial = false
end end
@@ -36,96 +23,19 @@ module Liquid
Liquid::BlockBody.new Liquid::BlockBody.new
end end
def new_parser(input) def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false)
@string_scanner.string = input Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag)
Parser.new(
@string_scanner,
reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid,
allow_calls: @error_mode == :strict2,
)
end end
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) def parse_expression(markup)
Tokenizer.new( Expression.parse(markup)
source: source,
string_scanner: @string_scanner,
line_number: start_line_number,
for_liquid_tag: for_liquid_tag,
)
end
def safe_parse_expression(parser)
Expression.safe_parse(parser, @string_scanner, @expression_cache)
end
def parse_expression(markup, safe: false)
if !safe && @error_mode == :strict2
# parse_expression is a widely used API. To maintain backward
# compatibility while raising awareness about strict2 parser standards,
# the safe flag supports API users make a deliberate decision.
#
# In strict2 mode, markup MUST come from parser.expression, which returns
# markup or a parsed callable node. We're not calling the parser here to
# prevent redundant parser overhead.
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
end
Expression.parse(markup, @string_scanner, @expression_cache)
end
def with_document_scope(&block)
previous_usage = @uses_local_bindings
@uses_local_bindings = false
with_lexical_scope(&block)
@uses_local_bindings
ensure
@uses_local_bindings = previous_usage
end
def with_lexical_scope(parameters = Const::EMPTY_ARRAY)
previous_scope = @lexical_scope
@lexical_scope = parameters.to_h { |name| [name, true] }
yield
ensure
@lexical_scope = previous_scope
end
def local_declarations?
@lexical_scope && !@lexical_scope.empty?
end
def declare_local(name)
@lexical_scope ||= {}
raise SyntaxError, "Duplicate let declaration '#{name}'" if @lexical_scope.key?(name)
@lexical_scope[name] = true
end
def with_closure(parameters = Const::EMPTY_ARRAY, &block)
require_closures!
previous_closure = @in_closure
@in_closure = true
with_lexical_scope(parameters, &block)
ensure
@in_closure = previous_closure
end
def in_closure?
@in_closure
end
def require_closures!
@uses_local_bindings = true
return if @error_mode == :strict2
raise SyntaxError, "Closures require strict2 mode"
end end
def partial=(value) def partial=(value)
@partial = value @partial = value
@options = value ? partial_options : @template_options @options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || @environment.error_mode @error_mode = @options[:error_mode] || Template.error_mode
end end
def partial_options def partial_options
+1 -1
View File
@@ -36,7 +36,7 @@ module Liquid
protected protected
def children def children
@node.respond_to?(:nodelist) ? Array(@node.nodelist) : Const::EMPTY_ARRAY @node.respond_to?(:nodelist) ? Array(@node.nodelist) : []
end end
end end
end end
+4 -81
View File
@@ -2,12 +2,10 @@
module Liquid module Liquid
class Parser class Parser
def initialize(input, reject_bare_brackets: false, allow_calls: false) def initialize(input)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input) l = Lexer.new(input)
@tokens = Lexer.tokenize(ss) @tokens = l.tokenize
@p = 0 # pointer to current location @p = 0 # pointer to current location
@reject_bare_brackets = reject_bare_brackets
@allow_calls = allow_calls
end end
def jump(point) def jump(point)
@@ -52,15 +50,10 @@ module Liquid
token = @tokens[@p] token = @tokens[@p]
case token[0] case token[0]
when :id when :id
return callable_or_lookup if @allow_calls
str = consume str = consume
str << variable_lookups str << variable_lookups
when :open_square when :open_square
if @reject_bare_brackets str = consume
raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead"
end
str = consume.dup
str << expression str << expression
str << consume(:close_square) str << consume(:close_square)
str << variable_lookups str << variable_lookups
@@ -72,41 +65,12 @@ module Liquid
consume(:dotdot) consume(:dotdot)
last = expression last = expression
consume(:close_round) consume(:close_round)
return RangeLookup.new(Expression.parse(first), Expression.parse(last)) unless first.is_a?(String) && last.is_a?(String)
"(#{first}..#{last})" "(#{first}..#{last})"
else else
raise SyntaxError, "#{token} is not a valid expression" raise SyntaxError, "#{token} is not a valid expression"
end end
end end
def expression_with_markup
start = @p
# Parsing lookups appends to consumed identifier strings.
markup_tokens = @tokens[start...-1].map { |token| token[1].dup }
value = expression
markup = value.is_a?(String) ? value : markup_tokens.take(@p - start).join
[value, markup]
end
def remaining_markup
@tokens[@p...-1].map { |token| token[1] }.join(' ')
end
def parameters
parameters = []
return parameters unless consume?(:open_round)
unless look(:close_round)
parameters << consume(:id)
parameters << consume(:id) while consume?(:comma) && !look(:close_round)
end
consume(:close_round)
raise SyntaxError, "Duplicate closure parameter" unless parameters.uniq.length == parameters.length
parameters
end
def argument def argument
str = +"" str = +""
# might be a keyword argument (identifier: expression) # might be a keyword argument (identifier: expression)
@@ -134,46 +98,5 @@ module Liquid
end end
str str
end end
private
def callable_or_lookup
target = callable_lookups(consume(:id))
return target unless consume?(:open_round)
arguments = []
unless look(:close_round)
arguments << Expression.parse(expression)
arguments << Expression.parse(expression) while consume?(:comma) && !look(:close_round)
end
consume(:close_round)
target = VariableLookup.parse(target) if target.is_a?(String)
CallExpression.new(target, arguments)
end
def callable_lookups(target)
loop do
if consume?(:dot)
target = append_lookup(target, consume(:id), command: true)
next
end
break unless consume?(:open_square)
key = expression
consume(:close_square)
target = append_lookup(target, key)
end
target
end
def append_lookup(target, key, command: false)
if target.is_a?(String) && key.is_a?(String)
return target << (command ? ".#{key}" : "[#{key}]")
end
target = VariableLookup.parse(target) if target.is_a?(String)
target.add_lookup(command ? key : Expression.parse(key), command: command)
target
end
end end
end end
+3 -36
View File
@@ -2,25 +2,10 @@
module Liquid module Liquid
module ParserSwitching module ParserSwitching
# Do not use this.
#
# It's basically doing the same thing the {#parse_with_selected_parser},
# except this will try the strict parser regardless of the error mode,
# and fall back to the lax parser if the error mode is lax or warn,
# except when in strict2 mode where it uses the strict2 parser.
#
# @deprecated Use {#parse_with_selected_parser} instead.
def strict_parse_with_error_mode_fallback(markup) def strict_parse_with_error_mode_fallback(markup)
return strict2_parse_with_error_context(markup) if strict2_mode?
strict_parse_with_error_context(markup) strict_parse_with_error_context(markup)
rescue SyntaxError => e rescue SyntaxError => e
case parse_context.error_mode case parse_context.error_mode
when :rigid
rigid_warn
raise
when :strict2
raise
when :strict when :strict
raise raise
when :warn when :warn
@@ -31,13 +16,11 @@ module Liquid
def parse_with_selected_parser(markup) def parse_with_selected_parser(markup)
case parse_context.error_mode case parse_context.error_mode
when :rigid then rigid_warn && strict2_parse_with_error_context(markup) when :strict then strict_parse_with_error_context(markup)
when :strict2 then strict2_parse_with_error_context(markup) when :lax then lax_parse(markup)
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :warn when :warn
begin begin
strict2_parse_with_error_context(markup) strict_parse_with_error_context(markup)
rescue SyntaxError => e rescue SyntaxError => e
parse_context.warnings << e parse_context.warnings << e
lax_parse(markup) lax_parse(markup)
@@ -45,24 +28,8 @@ module Liquid
end end
end end
def strict2_mode?
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
end
private private
def rigid_warn
Deprecations.warn(':rigid', ':strict2')
end
def strict2_parse_with_error_context(markup)
strict2_parse(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
def strict_parse_with_error_context(markup) def strict_parse_with_error_context(markup)
strict_parse(markup) strict_parse(markup)
rescue SyntaxError => e rescue SyntaxError => e
+3 -12
View File
@@ -4,8 +4,7 @@ module Liquid
class PartialCache class PartialCache
def self.load(template_name, context:, parse_context:) def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials] cached_partials = context.registers[:cached_partials]
cache_key = "#{template_name}:#{parse_context.error_mode}" cached = cached_partials[template_name]
cached = cached_partials[cache_key]
return cached if cached return cached if cached
file_system = context.registers[:file_system] file_system = context.registers[:file_system]
@@ -16,16 +15,8 @@ module Liquid
template_factory = context.registers[:template_factory] template_factory = context.registers[:template_factory]
template = template_factory.for(template_name) template = template_factory.for(template_name)
begin partial = template.parse(source, parse_context)
partial = template.parse(source, parse_context) cached_partials[template_name] = partial
rescue Liquid::Error => e
e.template_name = template&.name || template_name
raise e
end
partial.name ||= template_name
cached_partials[cache_key] = partial
ensure ensure
parse_context.partial = false parse_context.partial = false
end end
+3 -3
View File
@@ -2,9 +2,9 @@
module Liquid module Liquid
class RangeLookup class RangeLookup
def self.parse(start_markup, end_markup, string_scanner, cache = nil) def self.parse(start_markup, end_markup)
start_obj = Expression.parse(start_markup, string_scanner, cache) start_obj = Expression.parse(start_markup)
end_obj = Expression.parse(end_markup, string_scanner, cache) end_obj = Expression.parse(end_markup)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj) new(start_obj, end_obj)
else else
+5 -22
View File
@@ -2,39 +2,24 @@
module Liquid module Liquid
class ResourceLimits class ResourceLimits
attr_accessor :render_length_limit, attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
:render_score_limit, attr_reader :render_score, :assign_score
:assign_score_limit,
:cumulative_render_score_limit,
:cumulative_assign_score_limit
attr_reader :render_score,
:assign_score,
:cumulative_render_score,
:cumulative_assign_score
def initialize(limits) def initialize(limits)
@render_length_limit = limits[:render_length_limit] @render_length_limit = limits[:render_length_limit]
@render_score_limit = limits[:render_score_limit] @render_score_limit = limits[:render_score_limit]
@assign_score_limit = limits[:assign_score_limit] @assign_score_limit = limits[:assign_score_limit]
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
@cumulative_render_score = 0
@cumulative_assign_score = 0
reset reset
end end
def increment_render_score(amount) def increment_render_score(amount)
@render_score += amount @render_score += amount
@cumulative_render_score += amount
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
end end
def increment_assign_score(amount) def increment_assign_score(amount)
@assign_score += amount @assign_score += amount
@cumulative_assign_score += amount
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end end
# update either render_length or assign_score based on whether or not the writes are captured # update either render_length or assign_score based on whether or not the writes are captured
@@ -62,8 +47,6 @@ module Liquid
@reached_limit = false @reached_limit = false
@last_capture_length = nil @last_capture_length = nil
@render_score = @assign_score = 0 @render_score = @assign_score = 0
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end end
def with_capture def with_capture
-54
View File
@@ -1,54 +0,0 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type object
# @liquid_name self
# @liquid_summary
# Provides access to variables through the current scope chain.
# @liquid_description
# The `self` object resolves variables through the normal lookup hierarchy
# (local > file > global) without exposing filters, interrupts, errors,
# or other context internals. It's used when bare bracket notation
# (`['variable']`) needs to be replaced with an explicit variable lookup.
#
# If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`),
# then the local value takes precedence over the `self` object.
# @liquid_access global
class SelfDrop < Drop
def initialize(self_context)
super()
@self_context = self_context
end
def [](key)
@self_context.find_variable(key)
rescue UndefinedVariable
nil
end
def key?(key)
@self_context.variable_defined?(key)
end
def to_liquid
self
end
def ==(other)
other.is_a?(SelfDrop) && other.self_context.equal?(@self_context)
end
alias_method :eql?, :==
def hash
@self_context.object_id.hash
end
protected
attr_reader :self_context
undef context=
end
end
+77 -240
View File
@@ -3,24 +3,16 @@
require 'cgi' require 'cgi'
require 'base64' require 'base64'
require 'bigdecimal' require 'bigdecimal'
module Liquid module Liquid
module StandardFilters module StandardFilters
MAX_I32 = (1 << 31) - 1 MAX_I32 = (1 << 31) - 1
private_constant :MAX_I32 private_constant :MAX_I32
supports_64bit_indices = begin MIN_I64 = -(1 << 63)
[][1 << 33, 1 << 33] MAX_I64 = (1 << 63) - 1
true I64_RANGE = MIN_I64..MAX_I64
rescue RangeError private_constant :MIN_I64, :MAX_I64, :I64_RANGE
false
end
INDEX_RANGE = if supports_64bit_indices
(-(1 << 63))..((1 << 63) - 1)
else
(-(1 << 31))..((1 << 31) - 1)
end
private_constant :INDEX_RANGE
HTML_ESCAPE = { HTML_ESCAPE = {
'&' => '&amp;', '&' => '&amp;',
@@ -33,23 +25,10 @@ module Liquid
STRIP_HTML_BLOCKS = Regexp.union( STRIP_HTML_BLOCKS = Regexp.union(
%r{<script.*?</script>}m, %r{<script.*?</script>}m,
/<!--.*?-->/m, /<!--.*?-->/m,
%r{<style.*?</style>}m, %r{<style.*?</style>}m
) )
STRIP_HTML_TAGS = /<.*?>/m STRIP_HTML_TAGS = /<.*?>/m
class << self
def try_coerce_encoding(input, encoding:)
original_encoding = input.encoding
if input.encoding != encoding
input.force_encoding(encoding)
unless input.valid_encoding?
input.force_encoding(original_encoding)
end
end
input
end
end
# @liquid_public_docs # @liquid_public_docs
# @liquid_type filter # @liquid_type filter
# @liquid_category array # @liquid_category array
@@ -72,7 +51,7 @@ module Liquid
# @liquid_syntax string | downcase # @liquid_syntax string | downcase
# @liquid_return [string] # @liquid_return [string]
def downcase(input) def downcase(input)
Utils.to_s(input).downcase input.to_s.downcase
end end
# @liquid_public_docs # @liquid_public_docs
@@ -83,18 +62,18 @@ module Liquid
# @liquid_syntax string | upcase # @liquid_syntax string | upcase
# @liquid_return [string] # @liquid_return [string]
def upcase(input) def upcase(input)
Utils.to_s(input).upcase input.to_s.upcase
end end
# @liquid_public_docs # @liquid_public_docs
# @liquid_type filter # @liquid_type filter
# @liquid_category string # @liquid_category string
# @liquid_summary # @liquid_summary
# Capitalizes the first word in a string and downcases the remaining characters. # Capitalizes the first word in a string.
# @liquid_syntax string | capitalize # @liquid_syntax string | capitalize
# @liquid_return [string] # @liquid_return [string]
def capitalize(input) def capitalize(input)
Utils.to_s(input).capitalize input.to_s.capitalize
end end
# @liquid_public_docs # @liquid_public_docs
@@ -105,7 +84,7 @@ module Liquid
# @liquid_syntax string | escape # @liquid_syntax string | escape
# @liquid_return [string] # @liquid_return [string]
def escape(input) def escape(input)
CGI.escapeHTML(Utils.to_s(input)) unless input.nil? CGI.escapeHTML(input.to_s) unless input.nil?
end end
alias_method :h, :escape alias_method :h, :escape
@@ -117,7 +96,7 @@ module Liquid
# @liquid_syntax string | escape_once # @liquid_syntax string | escape_once
# @liquid_return [string] # @liquid_return [string]
def escape_once(input) def escape_once(input)
Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -132,7 +111,7 @@ module Liquid
# @liquid_syntax string | url_encode # @liquid_syntax string | url_encode
# @liquid_return [string] # @liquid_return [string]
def url_encode(input) def url_encode(input)
CGI.escape(Utils.to_s(input)) unless input.nil? CGI.escape(input.to_s) unless input.nil?
end end
# @liquid_public_docs # @liquid_public_docs
@@ -146,7 +125,7 @@ module Liquid
def url_decode(input) def url_decode(input)
return if input.nil? return if input.nil?
result = CGI.unescape(Utils.to_s(input)) result = CGI.unescape(input.to_s)
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding? raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
result result
@@ -160,7 +139,7 @@ module Liquid
# @liquid_syntax string | base64_encode # @liquid_syntax string | base64_encode
# @liquid_return [string] # @liquid_return [string]
def base64_encode(input) def base64_encode(input)
Base64.strict_encode64(Utils.to_s(input)) Base64.strict_encode64(input.to_s)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -171,8 +150,7 @@ module Liquid
# @liquid_syntax string | base64_decode # @liquid_syntax string | base64_decode
# @liquid_return [string] # @liquid_return [string]
def base64_decode(input) def base64_decode(input)
input = Utils.to_s(input) Base64.strict_decode64(input.to_s)
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode" raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
end end
@@ -185,7 +163,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_encode # @liquid_syntax string | base64_url_safe_encode
# @liquid_return [string] # @liquid_return [string]
def base64_url_safe_encode(input) def base64_url_safe_encode(input)
Base64.urlsafe_encode64(Utils.to_s(input)) Base64.urlsafe_encode64(input.to_s)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -196,8 +174,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_decode # @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string] # @liquid_return [string]
def base64_url_safe_decode(input) def base64_url_safe_decode(input)
input = Utils.to_s(input) Base64.urlsafe_decode64(input.to_s)
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode" raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
end end
@@ -220,14 +197,14 @@ module Liquid
if input.is_a?(Array) if input.is_a?(Array)
input.slice(offset, length) || [] input.slice(offset, length) || []
else else
Utils.to_s(input).slice(offset, length) || '' input.to_s.slice(offset, length) || ''
end end
rescue RangeError rescue RangeError
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset) if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
raise # unexpected error raise # unexpected error
end end
offset = offset.clamp(INDEX_RANGE) offset = offset.clamp(I64_RANGE)
length = length.clamp(INDEX_RANGE) length = length.clamp(I64_RANGE)
retry retry
end end
end end
@@ -244,10 +221,10 @@ module Liquid
# @liquid_return [string] # @liquid_return [string]
def truncate(input, length = 50, truncate_string = "...") def truncate(input, length = 50, truncate_string = "...")
return if input.nil? return if input.nil?
input_str = Utils.to_s(input) input_str = input.to_s
length = Utils.to_integer(length) length = Utils.to_integer(length)
truncate_string_str = Utils.to_s(truncate_string) truncate_string_str = truncate_string.to_s
l = length - truncate_string_str.length l = length - truncate_string_str.length
l = 0 if l < 0 l = 0 if l < 0
@@ -271,7 +248,7 @@ module Liquid
# @liquid_return [string] # @liquid_return [string]
def truncatewords(input, words = 15, truncate_string = "...") def truncatewords(input, words = 15, truncate_string = "...")
return if input.nil? return if input.nil?
input = Utils.to_s(input) input = input.to_s
words = Utils.to_integer(words) words = Utils.to_integer(words)
words = 1 if words <= 0 words = 1 if words <= 0
@@ -285,8 +262,7 @@ module Liquid
return input if wordlist.length <= words return input if wordlist.length <= words
wordlist.pop wordlist.pop
truncate_string = Utils.to_s(truncate_string) wordlist.join(" ").concat(truncate_string.to_s)
wordlist.join(" ").concat(truncate_string)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -297,22 +273,7 @@ module Liquid
# @liquid_syntax string | split: string # @liquid_syntax string | split: string
# @liquid_return [array[string]] # @liquid_return [array[string]]
def split(input, pattern) def split(input, pattern)
pattern = Utils.to_s(pattern) input.to_s.split(pattern.to_s)
input = Utils.to_s(input)
input.split(pattern)
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
# @liquid_summary
# Removes leading and trailing whitespace and collapses consecutive whitespace to a single space.
# @liquid_syntax string | squish
# @liquid_return [string]
def squish(input)
return if input.nil?
Utils.to_s(input).strip.gsub(/\s+/, ' ')
end end
# @liquid_public_docs # @liquid_public_docs
@@ -323,8 +284,7 @@ module Liquid
# @liquid_syntax string | strip # @liquid_syntax string | strip
# @liquid_return [string] # @liquid_return [string]
def strip(input) def strip(input)
input = Utils.to_s(input) input.to_s.strip
input.strip
end end
# @liquid_public_docs # @liquid_public_docs
@@ -335,8 +295,7 @@ module Liquid
# @liquid_syntax string | lstrip # @liquid_syntax string | lstrip
# @liquid_return [string] # @liquid_return [string]
def lstrip(input) def lstrip(input)
input = Utils.to_s(input) input.to_s.lstrip
input.lstrip
end end
# @liquid_public_docs # @liquid_public_docs
@@ -347,8 +306,7 @@ module Liquid
# @liquid_syntax string | rstrip # @liquid_syntax string | rstrip
# @liquid_return [string] # @liquid_return [string]
def rstrip(input) def rstrip(input)
input = Utils.to_s(input) input.to_s.rstrip
input.rstrip
end end
# @liquid_public_docs # @liquid_public_docs
@@ -359,9 +317,8 @@ module Liquid
# @liquid_syntax string | strip_html # @liquid_syntax string | strip_html
# @liquid_return [string] # @liquid_return [string]
def strip_html(input) def strip_html(input)
input = Utils.to_s(input)
empty = '' empty = ''
result = input.gsub(STRIP_HTML_BLOCKS, empty) result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
result.gsub!(STRIP_HTML_TAGS, empty) result.gsub!(STRIP_HTML_TAGS, empty)
result result
end end
@@ -374,8 +331,7 @@ module Liquid
# @liquid_syntax string | strip_newlines # @liquid_syntax string | strip_newlines
# @liquid_return [string] # @liquid_return [string]
def strip_newlines(input) def strip_newlines(input)
input = Utils.to_s(input) input.to_s.gsub(/\r?\n/, '')
input.gsub(/\r?\n/, '')
end end
# @liquid_public_docs # @liquid_public_docs
@@ -386,7 +342,6 @@ module Liquid
# @liquid_syntax array | join # @liquid_syntax array | join
# @liquid_return [string] # @liquid_return [string]
def join(input, glue = ' ') def join(input, glue = ' ')
glue = Utils.to_s(glue)
InputIterator.new(input, context).join(glue) InputIterator.new(input, context).join(glue)
end end
@@ -454,59 +409,29 @@ module Liquid
# @liquid_syntax array | where: string, string # @liquid_syntax array | where: string, string
# @liquid_return [array[untyped]] # @liquid_return [array[untyped]]
def where(input, property, target_value = nil) def where(input, property, target_value = nil)
filter_array(input, property, target_value) { |ary, &block| ary.select(&block) } ary = InputIterator.new(input, context)
end
# @liquid_public_docs if ary.empty?
# @liquid_type filter []
# @liquid_category array elsif target_value.nil?
# @liquid_summary ary.select do |item|
# Filters an array to exclude items with a specific property value. item[property]
# @liquid_description rescue TypeError
# This requires you to provide both the property name and the associated value. raise_property_error(property)
# @liquid_syntax array | reject: string, string rescue NoMethodError
# @liquid_return [array[untyped]] return nil unless item.respond_to?(:[])
def reject(input, property, target_value = nil) raise
filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) } end
end else
ary.select do |item|
# @liquid_public_docs item[property] == target_value
# @liquid_type filter rescue TypeError
# @liquid_category array raise_property_error(property)
# @liquid_summary rescue NoMethodError
# Tests if any item in an array has a specific property value. return nil unless item.respond_to?(:[])
# @liquid_description raise
# This requires you to provide both the property name and the associated value. end
# @liquid_syntax array | has: string, string end
# @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 end
# @liquid_public_docs # @liquid_public_docs
@@ -603,10 +528,7 @@ module Liquid
# @liquid_syntax string | replace: string, string # @liquid_syntax string | replace: string, string
# @liquid_return [string] # @liquid_return [string]
def replace(input, string, replacement = '') def replace(input, string, replacement = '')
string = Utils.to_s(string) input.to_s.gsub(string.to_s, replacement.to_s)
replacement = Utils.to_s(replacement)
input = Utils.to_s(input)
input.gsub(string, replacement)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -617,10 +539,7 @@ module Liquid
# @liquid_syntax string | replace_first: string, string # @liquid_syntax string | replace_first: string, string
# @liquid_return [string] # @liquid_return [string]
def replace_first(input, string, replacement = '') def replace_first(input, string, replacement = '')
string = Utils.to_s(string) input.to_s.sub(string.to_s, replacement.to_s)
replacement = Utils.to_s(replacement)
input = Utils.to_s(input)
input.sub(string, replacement)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -631,9 +550,9 @@ module Liquid
# @liquid_syntax string | replace_last: string, string # @liquid_syntax string | replace_last: string, string
# @liquid_return [string] # @liquid_return [string]
def replace_last(input, string, replacement) def replace_last(input, string, replacement)
input = Utils.to_s(input) input = input.to_s
string = Utils.to_s(string) string = string.to_s
replacement = Utils.to_s(replacement) replacement = replacement.to_s
start_index = input.rindex(string) start_index = input.rindex(string)
@@ -685,9 +604,7 @@ module Liquid
# @liquid_syntax string | append: string # @liquid_syntax string | append: string
# @liquid_return [string] # @liquid_return [string]
def append(input, string) def append(input, string)
input = Utils.to_s(input) input.to_s + string.to_s
string = Utils.to_s(string)
input + string
end end
# @liquid_public_docs # @liquid_public_docs
@@ -698,7 +615,7 @@ module Liquid
# @liquid_description # @liquid_description
# > Note: # > Note:
# > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the # > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the
# > [`uniq` filter](/docs/api/liquid/filters/uniq). # > [`uniq` filter](/api/liquid/filters/uniq).
# @liquid_syntax array | concat: array # @liquid_syntax array | concat: array
# @liquid_return [array[untyped]] # @liquid_return [array[untyped]]
def concat(input, array) def concat(input, array)
@@ -716,9 +633,7 @@ module Liquid
# @liquid_syntax string | prepend: string # @liquid_syntax string | prepend: string
# @liquid_return [string] # @liquid_return [string]
def prepend(input, string) def prepend(input, string)
input = Utils.to_s(input) string.to_s + input.to_s
string = Utils.to_s(string)
string + input
end end
# @liquid_public_docs # @liquid_public_docs
@@ -729,20 +644,10 @@ module Liquid
# @liquid_syntax string | newline_to_br # @liquid_syntax string | newline_to_br
# @liquid_return [string] # @liquid_return [string]
def newline_to_br(input) def newline_to_br(input)
input = Utils.to_s(input) input.to_s.gsub(/\r?\n/, "<br />\n")
input.gsub(/\r?\n/, "<br />\n")
end end
# @liquid_public_docs # Reformat a date using Ruby's core Time#strftime( string ) -> string
# @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 abbreviated weekday name (``Sun'')
# %A - The full weekday name (``Sunday'') # %A - The full weekday name (``Sunday'')
@@ -771,15 +676,14 @@ module Liquid
# %Y - Year with century # %Y - Year with century
# %Z - Time zone name # %Z - Time zone name
# %% - Literal ``%'' character # %% - Literal ``%'' character
# @liquid_syntax date | date: string #
# @liquid_return [string] # See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
def date(input, format) def date(input, format)
str_format = Utils.to_s(format) return input if format.to_s.empty?
return input if str_format.empty?
return input unless (date = Utils.to_date(input)) return input unless (date = Utils.to_date(input))
date.strftime(str_format) date.strftime(format.to_s)
end end
# @liquid_public_docs # @liquid_public_docs
@@ -790,8 +694,6 @@ module Liquid
# @liquid_syntax array | first # @liquid_syntax array | first
# @liquid_return [untyped] # @liquid_return [untyped]
def first(array) def first(array)
# ActiveSupport returns "" for empty strings, not nil
return array[0] || "" if array.is_a?(String)
array.first if array.respond_to?(:first) array.first if array.respond_to?(:first)
end end
@@ -803,8 +705,6 @@ module Liquid
# @liquid_syntax array | last # @liquid_syntax array | last
# @liquid_return [untyped] # @liquid_return [untyped]
def last(array) def last(array)
# ActiveSupport returns "" for empty strings, not nil
return array[-1] || "" if array.is_a?(String)
array.last if array.respond_to?(:last) array.last if array.respond_to?(:last)
end end
@@ -957,73 +857,24 @@ module Liquid
# @liquid_summary # @liquid_summary
# Sets a default value for any variable whose value is one of the following: # Sets a default value for any variable whose value is one of the following:
# #
# - [`empty`](/docs/api/liquid/basics#empty) # - [`empty`](/api/liquid/basics#empty)
# - [`false`](/docs/api/liquid/basics#truthy-and-falsy) # - [`false`](/api/liquid/basics#truthy-and-falsy)
# - [`nil`](/docs/api/liquid/basics#nil) # - [`nil`](/api/liquid/basics#nil)
# @liquid_syntax variable | default: variable # @liquid_syntax variable | default: variable
# @liquid_return [untyped] # @liquid_return [untyped]
# @liquid_optional_param allow_false: [boolean] Whether to use false values instead of the default. # @liquid_optional_param allow_false [boolean] Whether to use false values instead of the default.
def default(input, default_value = '', options = {}) def default(input, default_value = '', options = {})
options = {} unless options.is_a?(Hash) options = {} unless options.is_a?(Hash)
false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input) false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input)
false_check || (input.respond_to?(:empty?) && input.empty?) ? default_value : input false_check || (input.respond_to?(:empty?) && input.empty?) ? default_value : input
end end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Returns the sum of all elements in an array.
# @liquid_syntax array | sum
# @liquid_return [number]
def sum(input, property = nil)
ary = InputIterator.new(input, context)
return 0 if ary.empty?
values_for_sum = ary.map do |item|
if property.nil?
item
elsif item.respond_to?(:[])
item[property]
else
0
end
rescue TypeError
raise_property_error(property)
end
result = InputIterator.new(values_for_sum, context).sum do |item|
Utils.to_number(item)
end
result.is_a?(BigDecimal) ? result.to_f : result
end
private private
attr_reader :context 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) def raise_property_error(property)
raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'" raise Liquid::ArgumentError, "cannot select the property '#{property}'"
end end
def apply_operation(input, operand, operation) def apply_operation(input, operand, operation)
@@ -1048,8 +899,6 @@ module Liquid
def nil_safe_casecmp(a, b) def nil_safe_casecmp(a, b)
if !a.nil? && !b.nil? if !a.nil? && !b.nil?
a.to_s.casecmp(b.to_s) a.to_s.casecmp(b.to_s)
elsif a.nil? && b.nil?
0
else else
a.nil? ? 1 : -1 a.nil? ? 1 : -1
end end
@@ -1072,18 +921,7 @@ module Liquid
end end
def join(glue) def join(glue)
first = true to_a.join(glue.to_s)
output = +""
each do |item|
if first
first = false
else
output << glue
end
output << Liquid::Utils.to_s(item)
end
output
end end
def concat(args) def concat(args)
@@ -1095,10 +933,7 @@ module Liquid
end end
def uniq(&block) def uniq(&block)
to_a.uniq do |item| to_a.uniq(&block)
item = Utils.to_liquid_value(item)
block ? yield(item) : item
end
end end
def compact def compact
@@ -1119,4 +954,6 @@ module Liquid
end end
end end
end end
Template.register_filter(StandardFilters)
end end
+41
View File
@@ -0,0 +1,41 @@
# frozen_string_literal: true
module Liquid
# StrainerFactory is the factory for the filters system.
module StrainerFactory
extend self
def add_global_filter(filter)
strainer_class_cache.clear
GlobalCache.add_filter(filter)
end
def create(context, filters = [])
strainer_from_cache(filters).new(context)
end
def global_filter_names
GlobalCache.filter_method_names
end
GlobalCache = Class.new(StrainerTemplate)
private
def strainer_from_cache(filters)
if filters.empty?
GlobalCache
else
strainer_class_cache[filters] ||= begin
klass = Class.new(GlobalCache)
filters.each { |f| klass.add_filter(f) }
klass
end
end
end
def strainer_class_cache
@strainer_class_cache ||= {}
end
end
end
+1 -1
View File
@@ -5,7 +5,7 @@ module Liquid
# @liquid_type object # @liquid_type object
# @liquid_name tablerowloop # @liquid_name tablerowloop
# @liquid_summary # @liquid_summary
# Information about a parent [`tablerow` loop](/docs/api/liquid/tags/tablerow). # Information about a parent [`tablerow` loop](/api/liquid/tags/tablerow).
class TablerowloopDrop < Drop class TablerowloopDrop < Drop
def initialize(length, cols) def initialize(length, cols)
@length = length @length = length
+3 -11
View File
@@ -1,8 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'liquid/tag/disabler'
require 'liquid/tag/disableable'
module Liquid module Liquid
class Tag class Tag
attr_reader :nodelist, :tag_name, :line_number, :parse_context attr_reader :nodelist, :tag_name, :line_number, :parse_context
@@ -57,8 +54,7 @@ module Liquid
# of the `render_to_output_buffer` method will become the default and the `render` # of the `render_to_output_buffer` method will become the default and the `render`
# method will be removed. # method will be removed.
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
render_result = render(context) output << render(context)
output << render_result if render_result
output output
end end
@@ -68,12 +64,8 @@ module Liquid
private private
def safe_parse_expression(parser) def parse_expression(markup)
parse_context.safe_parse_expression(parser) parse_context.parse_expression(markup)
end
def parse_expression(markup, safe: false)
parse_context.parse_expression(markup, safe: safe)
end end
end end
end end
-53
View File
@@ -1,53 +0,0 @@
# frozen_string_literal: true
require_relative "tags/table_row"
require_relative "tags/echo"
require_relative "tags/if"
require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
require_relative "tags/let"
require_relative "tags/set"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
require_relative "tags/continue"
require_relative "tags/capture"
require_relative "tags/decrement"
require_relative "tags/unless"
require_relative "tags/increment"
require_relative "tags/comment"
require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
require_relative "tags/doc"
module Liquid
module Tags
STANDARD_TAGS = {
'cycle' => Cycle,
'render' => Render,
'raw' => Raw,
'comment' => Comment,
'increment' => Increment,
'unless' => Unless,
'decrement' => Decrement,
'capture' => Capture,
'continue' => Continue,
'include' => Include,
'case' => Case,
'ifchanged' => Ifchanged,
'assign' => Assign,
'let' => Let,
'set' => SetTag,
'for' => For,
'#' => InlineComment,
'break' => Break,
'if' => If,
'echo' => Echo,
'tablerow' => TableRow,
'doc' => Doc,
}.freeze
end
end
+7 -35
View File
@@ -8,18 +8,12 @@ module Liquid
# @liquid_summary # @liquid_summary
# Creates a new variable. # Creates a new variable.
# @liquid_description # @liquid_description
# You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property. # You can create variables of any [basic type](/api/liquid/basics#types), [object](/api/liquid/objects), or object property.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_syntax # @liquid_syntax
# {% assign variable_name = value %} # {% assign variable_name = value %}
# @liquid_syntax_keyword variable_name The name of the variable being created. # @liquid_syntax_keyword variable_name The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable. # @liquid_syntax_keyword value The value you want to assign to the variable.
class Assign < Tag class Assign < Tag
include ParserSwitching
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
# @api private # @api private
@@ -31,12 +25,6 @@ module Liquid
def initialize(tag_name, markup, parse_context) def initialize(tag_name, markup, parse_context)
super super
raise SyntaxError, "assign is not allowed in closure bodies" if parse_context.in_closure?
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax if markup =~ Syntax
@to = Regexp.last_match(1) @to = Regexp.last_match(1)
@from = Variable.new(Regexp.last_match(2), parse_context) @from = Variable.new(Regexp.last_match(2), parse_context)
@@ -45,29 +33,10 @@ module Liquid
end end
end end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
unless markup =~ Syntax
self.class.raise_syntax_error(parse_context)
end
lhs = Regexp.last_match(1).strip
rhs = Regexp.last_match(2)
p = @parse_context.new_parser(lhs)
@to = p.consume(:id)
p.consume(:end_of_string)
@from = Variable.new(rhs, parse_context)
end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
val = @from.render(context) val = @from.render(context)
context.scopes.last[@to] = val context.scopes.last[@to] = val
context.resource_limits.increment_assign_score(self.class.assign_score_of(val)) context.resource_limits.increment_assign_score(assign_score_of(val))
output output
end end
@@ -75,8 +44,9 @@ module Liquid
true true
end end
# @api private private
def self.assign_score_of(val)
def assign_score_of(val)
if val.instance_of?(String) if val.instance_of?(String)
val.bytesize val.bytesize
elsif val.instance_of?(Array) elsif val.instance_of?(Array)
@@ -102,4 +72,6 @@ module Liquid
end end
end end
end end
Template.register_tag('assign', Assign)
end end
+3 -1
View File
@@ -15,7 +15,7 @@ module Liquid
# @liquid_category iteration # @liquid_category iteration
# @liquid_name break # @liquid_name break
# @liquid_summary # @liquid_summary
# Stops a [`for` loop](/docs/api/liquid/tags/for) from iterating. # Stops a [`for` loop](/api/liquid/tags/for) from iterating.
# @liquid_syntax # @liquid_syntax
# {% break %} # {% break %}
class Break < Tag class Break < Tag
@@ -26,4 +26,6 @@ module Liquid
output output
end end
end end
Template.register_tag('break', Break)
end end
+2 -22
View File
@@ -9,10 +9,6 @@ module Liquid
# Creates a new variable with a string value. # Creates a new variable with a string value.
# @liquid_description # @liquid_description
# You can create complex strings with Liquid logic and variables. # You can create complex strings with Liquid logic and variables.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_syntax # @liquid_syntax
# {% capture variable %} # {% capture variable %}
# value # value
@@ -20,18 +16,10 @@ module Liquid
# @liquid_syntax_keyword variable The name of the variable being created. # @liquid_syntax_keyword variable The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable. # @liquid_syntax_keyword value The value you want to assign to the variable.
class Capture < Block class Capture < Block
include ParserSwitching
Syntax = /(#{VariableSignature}+)/o Syntax = /(#{VariableSignature}+)/o
attr_reader :to
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax if markup =~ Syntax
@to = Regexp.last_match(1) @to = Regexp.last_match(1)
else else
@@ -39,16 +27,6 @@ module Liquid
end end
end end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@to = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
context.resource_limits.with_capture do context.resource_limits.with_capture do
capture_output = render(context) capture_output = render(context)
@@ -61,4 +39,6 @@ module Liquid
true true
end end
end end
Template.register_tag('capture', Capture)
end end
+9 -43
View File
@@ -31,7 +31,12 @@ module Liquid
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
@blocks = [] @blocks = []
parse_with_selected_parser(markup)
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
end end
def parse(tokens) def parse(tokens)
@@ -72,7 +77,7 @@ module Liquid
end end
result = Liquid::Utils.to_liquid_value( result = Liquid::Utils.to_liquid_value(
block.evaluate(context), block.evaluate(context)
) )
if result if result
@@ -86,50 +91,9 @@ module Liquid
private private
def strict2_parse(markup)
parser = @parse_context.new_parser(markup)
@left = safe_parse_expression(parser)
parser.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
end
def record_when_condition(markup) def record_when_condition(markup)
body = new_body body = new_body
if strict2_mode?
parse_strict2_when(markup, body)
else
parse_lax_when(markup, body)
end
end
def parse_strict2_when(markup, body)
parser = @parse_context.new_parser(markup)
loop do
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
block = Condition.new(@left, '==', expr)
block.attach(body)
@blocks << block
break unless parser.id?('or') || parser.consume?(:comma)
end
parser.consume(:end_of_string)
end
def parse_lax_when(markup, body)
while markup while markup
unless markup =~ WhenSyntax unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when") raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
@@ -159,4 +123,6 @@ module Liquid
end end
end end
end end
Template.register_tag('case', Case)
end end
+2 -59
View File
@@ -25,64 +25,7 @@ module Liquid
def blank? def blank?
true true
end end
private
def parse_body(body, tokenizer)
if parse_context.depth >= MAX_DEPTH
raise StackLevelError, "Nesting too deep"
end
parse_context.depth += 1
comment_tag_depth = 1
begin
# Consume tokens without creating child nodes.
# The children tag doesn't require to be a valid Liquid except the comment and raw tag.
# The child comment and raw tag must be closed.
while (token = tokenizer.send(:shift))
tag_name = if tokenizer.for_liquid_tag
next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing)
tag_name_match = BlockBody::LiquidTagToken.match(token)
next if tag_name_match.nil?
tag_name_match[1]
else
token =~ BlockBody::FullToken
Regexp.last_match(2)
end
case tag_name
when "raw"
parse_raw_tag_body(tokenizer)
when "comment"
comment_tag_depth += 1
when "endcomment"
comment_tag_depth -= 1
end
if comment_tag_depth.zero?
parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag
return false
end
end
raise_tag_never_closed(block_name)
ensure
parse_context.depth -= 1
end
false
end
def parse_raw_tag_body(tokenizer)
while (token = tokenizer.send(:shift))
return if token =~ BlockBody::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2)
end
raise_tag_never_closed("raw")
end
end end
Template.register_tag('comment', Comment)
end end
+3 -1
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category iteration # @liquid_category iteration
# @liquid_name continue # @liquid_name continue
# @liquid_summary # @liquid_summary
# Causes a [`for` loop](/docs/api/liquid/tags/for) to skip to the next iteration. # Causes a [`for` loop](/api/liquid/tags/for) to skip to the next iteration.
# @liquid_syntax # @liquid_syntax
# {% continue %} # {% continue %}
class Continue < Tag class Continue < Tag
@@ -17,4 +17,6 @@ module Liquid
output output
end end
end end
Template.register_tag('continue', Continue)
end end
+14 -77
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category iteration # @liquid_category iteration
# @liquid_name cycle # @liquid_name cycle
# @liquid_summary # @liquid_summary
# Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/docs/api/liquid/tags/for). # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/api/liquid/tags/for).
# @liquid_description # @liquid_description
# The `cycle` tag must be used inside a `for` loop. # The `cycle` tag must be used inside a `for` loop.
# #
@@ -17,17 +17,21 @@ module Liquid
class Cycle < Tag class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
attr_reader :variables attr_reader :variables
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup) case markup
end when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
def named? @name = parse_expression(Regexp.last_match(1))
@is_named when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
@@ -55,86 +59,19 @@ module Liquid
private private
# cycle [name:] expression(, expression)*
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variables = []
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
first_expression = safe_parse_expression(p)
if p.look(:colon)
# cycle name: expr1, expr2, ...
@name = first_expression
@is_named = true
p.consume(:colon)
# After the colon, parse the first variable (required for named cycles)
@variables << maybe_dup_lookup(safe_parse_expression(p))
else
# cycle expr1, expr2, ...
@variables << maybe_dup_lookup(first_expression)
end
# Parse remaining comma-separated expressions
while p.consume?(:comma)
break if p.look(:end_of_string)
@variables << maybe_dup_lookup(safe_parse_expression(p))
end
p.consume(:end_of_string)
unless @is_named
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
end
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
case markup
when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end
def variables_from_string(markup) def variables_from_string(markup)
markup.split(',').collect do |var| markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o var =~ /\s*(#{QuotedFragment})\s*/o
next unless Regexp.last_match(1) Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil
var = parse_expression(Regexp.last_match(1))
maybe_dup_lookup(var)
end.compact end.compact
end end
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
# we make it so that the @variables.to_s produces different strings for cycles
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
# This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value.
# This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter.
# I was really scratching my head about this one, but migrating away from this would be more headache
# than it's worth. So we're keeping this quirk for now.
def maybe_dup_lookup(var)
var.is_a?(VariableLookup) ? var.dup : var
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children def children
Array(@node.variables) Array(@node.variables)
end end
end end
end end
Template.register_tag('cycle', Cycle)
end end
+5 -23
View File
@@ -7,45 +7,25 @@ module Liquid
# @liquid_name decrement # @liquid_name decrement
# @liquid_summary # @liquid_summary
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call. # Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_description # @liquid_description
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # 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 # 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) # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share # and [`capture`](/api/liquid/tags/capture). However, `decrement` and [`increment`](/api/liquid/tags/increment) share
# variables. # variables.
# @liquid_syntax # @liquid_syntax
# {% decrement variable_name %} # {% decrement variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being decremented. # @liquid_syntax_keyword variable_name The name of the variable being decremented.
class Decrement < Tag class Decrement < Tag
include ParserSwitching
attr_reader :variable_name attr_reader :variable_name
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
@variable_name = markup.strip @variable_name = markup.strip
end end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@variable_name = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
counter_environment = context.environments.first counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0 value = counter_environment[@variable_name] || 0
@@ -55,4 +35,6 @@ module Liquid
output output
end end
end end
Template.register_tag('decrement', Decrement)
end end
-81
View File
@@ -1,81 +0,0 @@
# 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)
@body = +""
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)
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return
end
@body << token unless token.empty?
end
raise_tag_never_closed(block_name)
end
def render_to_output_buffer(_context, output)
output
end
def blank?
@body.empty?
end
def nodelist
[@body]
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
+4 -2
View File
@@ -9,10 +9,10 @@ module Liquid
# Outputs an expression. # Outputs an expression.
# @liquid_description # @liquid_description
# Using the `echo` tag is the same as wrapping an expression in curly brackets (`{{` and `}}`). However, unlike the curly # Using the `echo` tag is the same as wrapping an expression in curly brackets (`{{` and `}}`). However, unlike the curly
# bracket method, you can use the `echo` tag inside [`liquid` tags](/docs/api/liquid/tags/liquid). # bracket method, you can use the `echo` tag inside [`liquid` tags](/api/liquid/tags/liquid).
# #
# > Tip: # > Tip:
# > You can use [filters](/docs/api/liquid/filters) on expressions inside `echo` tags. # > You can use [filters](/api/liquid/filters) on expressions inside `echo` tags.
# @liquid_syntax # @liquid_syntax
# {% liquid # {% liquid
# echo expression # echo expression
@@ -36,4 +36,6 @@ module Liquid
end end
end end
end end
Template.register_tag('echo', Echo)
end end
+28 -42
View File
@@ -9,10 +9,10 @@ module Liquid
# Renders an expression for every item in an array. # Renders an expression for every item in an array.
# @liquid_description # @liquid_description
# You can do a maximum of 50 iterations with a `for` loop. If you need to iterate over more than 50 items, then use the # You can do a maximum of 50 iterations with a `for` loop. If you need to iterate over more than 50 items, then use the
# [`paginate` tag](/docs/api/liquid/tags/paginate) to split the items over multiple pages. # [`paginate` tag](/api/liquid/tags/paginate) to split the items over multiple pages.
# #
# > Tip: # > Tip:
# > Every `for` loop has an associated [`forloop` object](/docs/api/liquid/objects/forloop) with information about the loop. # > Every `for` loop has an associated [`forloop` object](/api/liquid/objects/forloop) with information about the loop.
# @liquid_syntax # @liquid_syntax
# {% for variable in array %} # {% for variable in array %}
# expression # expression
@@ -20,8 +20,8 @@ module Liquid
# @liquid_syntax_keyword variable The current item in the array. # @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over. # @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render for each iteration. # @liquid_syntax_keyword expression The expression to render for each iteration.
# @liquid_optional_param limit: [number] The number of iterations to perform. # @liquid_optional_param limit [number] The number of iterations to perform.
# @liquid_optional_param offset: [number] The 1-based index to start iterating at. # @liquid_optional_param offset [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over. # @liquid_optional_param range [untyped] A custom numeric range to iterate over.
# @liquid_optional_param reversed [untyped] Iterate in reverse order. # @liquid_optional_param reversed [untyped] Iterate in reverse order.
class For < Block class For < Block
@@ -38,10 +38,15 @@ module Liquid
end end
def parse(tokens) def parse(tokens)
parse_context.with_lexical_scope do if parse_body(@for_block, tokens)
parse_loop(tokens) parse_body(@else_block, tokens)
@has_local_declarations = parse_context.local_declarations?
end end
if blank?
@else_block&.remove_blank_strings
@for_block.remove_blank_strings
end
@else_block&.freeze
@for_block.freeze
end end
def nodelist def nodelist
@@ -83,14 +88,14 @@ module Liquid
end end
def strict_parse(markup) def strict_parse(markup)
p = @parse_context.new_parser(markup) p = Parser.new(markup)
@variable_name = p.consume(:id) @variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in') raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
collection_name, collection_markup = p.expression_with_markup collection_name = p.expression
@collection_name = parse_expression(collection_name, safe: true) @collection_name = parse_expression(collection_name)
@name = "#{@variable_name}-#{collection_markup}" @name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed') @reversed = p.id?('reversed')
while p.look(:comma) || p.look(:id) while p.look(:comma) || p.look(:id)
@@ -99,29 +104,13 @@ module Liquid
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute") raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end end
p.consume(:colon) p.consume(:colon)
set_attribute(attribute, p.expression, safe: true) set_attribute(attribute, p.expression)
end end
p.consume(:end_of_string) p.consume(:end_of_string)
end end
private private
def parse_loop(tokens)
if parse_body(@for_block, tokens)
parse_body(@else_block, tokens)
end
if blank?
@else_block&.remove_blank_strings
@for_block.remove_blank_strings
end
@else_block&.freeze
@for_block.freeze
end
def strict2_parse(markup)
strict_parse(markup)
end
def collection_segment(context) def collection_segment(context)
offsets = context.registers[:for] ||= {} offsets = context.registers[:for] ||= {}
@@ -168,7 +157,7 @@ module Liquid
segment.each do |item| segment.each do |item|
context[@variable_name] = item context[@variable_name] = item
render_iteration(context, output) @for_block.render_to_output_buffer(context, output)
loop_vars.send(:increment!) loop_vars.send(:increment!)
# Handle any interrupts if they exist. # Handle any interrupts if they exist.
@@ -185,30 +174,25 @@ module Liquid
output output
end end
def render_iteration(context, output) def set_attribute(key, expr)
return @for_block.render_to_output_buffer(context, output) unless @has_local_declarations
context.stack { @for_block.render_to_output_buffer(context, output) }
end
def set_attribute(key, expr, safe: false)
case key case key
when 'offset' when 'offset'
@from = if expr == 'continue' @from = if expr == 'continue'
:continue :continue
else else
parse_expression(expr, safe: safe) parse_expression(expr)
end end
when 'limit' when 'limit'
@limit = parse_expression(expr, safe: safe) @limit = parse_expression(expr)
end end
end end
def render_else(context, output) def render_else(context, output)
return output unless @else_block if @else_block
return @else_block.render_to_output_buffer(context, output) unless @has_local_declarations @else_block.render_to_output_buffer(context, output)
else
context.stack { @else_block.render_to_output_buffer(context, output) } output
end
end end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
@@ -217,4 +201,6 @@ module Liquid
end end
end end
end end
Template.register_tag('for', For)
end end
+9 -11
View File
@@ -53,7 +53,7 @@ module Liquid
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
@blocks.each do |block| @blocks.each do |block|
result = Liquid::Utils.to_liquid_value( result = Liquid::Utils.to_liquid_value(
block.evaluate(context), block.evaluate(context)
) )
if result if result
@@ -66,10 +66,6 @@ module Liquid
private private
def strict2_parse(markup)
strict_parse(markup)
end
def push_block(tag, markup) def push_block(tag, markup)
block = if tag == 'else' block = if tag == 'else'
ElseCondition.new ElseCondition.new
@@ -81,8 +77,8 @@ module Liquid
block.attach(new_body) block.attach(new_body)
end end
def parse_expression(markup, safe: false) def parse_expression(markup)
Condition.parse_expression(parse_context, markup, safe: safe) Condition.parse_expression(parse_context, markup)
end end
def lax_parse(markup) def lax_parse(markup)
@@ -106,7 +102,7 @@ module Liquid
end end
def strict_parse(markup) def strict_parse(markup)
p = @parse_context.new_parser(markup) p = Parser.new(markup)
condition = parse_binary_comparisons(p) condition = parse_binary_comparisons(p)
p.consume(:end_of_string) p.consume(:end_of_string)
condition condition
@@ -115,7 +111,7 @@ module Liquid
def parse_binary_comparisons(p) def parse_binary_comparisons(p)
condition = parse_comparison(p) condition = parse_comparison(p)
first_condition = condition first_condition = condition
while (op = p.id?('and') || p.id?('or')) while (op = (p.id?('and') || p.id?('or')))
child_condition = parse_comparison(p) child_condition = parse_comparison(p)
condition.send(op, child_condition) condition.send(op, child_condition)
condition = child_condition condition = child_condition
@@ -124,9 +120,9 @@ module Liquid
end end
def parse_comparison(p) def parse_comparison(p)
a = parse_expression(p.expression, safe: true) a = parse_expression(p.expression)
if (op = p.consume?(:comparison)) if (op = p.consume?(:comparison))
b = parse_expression(p.expression, safe: true) b = parse_expression(p.expression)
Condition.new(a, op, b) Condition.new(a, op, b)
else else
Condition.new(a) Condition.new(a)
@@ -139,4 +135,6 @@ module Liquid
end end
end end
end end
Template.register_tag('if', If)
end end
+2
View File
@@ -14,4 +14,6 @@ module Liquid
output output
end end
end end
Template.register_tag('ifchanged', Ifchanged)
end end
+27 -62
View File
@@ -6,9 +6,9 @@ module Liquid
# @liquid_category theme # @liquid_category theme
# @liquid_name include # @liquid_name include
# @liquid_summary # @liquid_summary
# Renders a [snippet](/themes/architecture/snippets). # Renders a [snippet](/themes/architecture#snippets).
# @liquid_description # @liquid_description
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the # Inside the snippet, you can access and alter variables that are [created](/api/liquid/tags/variable-tags) outside of the
# snippet. # snippet.
# @liquid_syntax # @liquid_syntax
# {% include 'filename' %} # {% include 'filename' %}
@@ -16,19 +16,35 @@ module Liquid
# @liquid_deprecated # @liquid_deprecated
# Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain. # Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain.
# #
# The `include` tag has been replaced by [`render`](/docs/api/liquid/tags/render). # The `include` tag has been replaced by [`render`](/api/liquid/tags/render).
class Include < Tag class Include < Tag
prepend Tag::Disableable prepend Tag::Disableable
FOR = 'for' SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
Syntax = SYNTAX Syntax = SYNTAX
attr_reader :template_name_expr, :variable_name_expr, :attributes attr_reader :template_name_expr, :variable_name_expr, :attributes
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup)
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
variable_name = Regexp.last_match(3)
@alias_name = Regexp.last_match(5)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.include")
end
end end
def parse(_tokens) def parse(_tokens)
@@ -41,7 +57,7 @@ module Liquid
partial = PartialCache.load( partial = PartialCache.load(
template_name, template_name,
context: context, context: context,
parse_context: parse_context, parse_context: parse_context
) )
context_variable_name = @alias_name || template_name.split('/').last context_variable_name = @alias_name || template_name.split('/').last
@@ -54,11 +70,9 @@ module Liquid
old_template_name = context.template_name old_template_name = context.template_name
old_partial = context.partial old_partial = context.partial
begin begin
context.template_name = partial.name context.template_name = template_name
context.partial = true context.partial = true
context.stack do context.stack do
@attributes.each do |key, value| @attributes.each do |key, value|
context[key] = context.evaluate(value) context[key] = context.evaluate(value)
@@ -85,57 +99,6 @@ module Liquid
alias_method :parse_context, :options alias_method :parse_context, :options
private :parse_context private :parse_context
def for_loop?
@is_for_loop
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = safe_parse_expression(p)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = safe_parse_expression(p) if with_or_for
@alias_name = p.consume(:id) if p.id?("as")
@is_for_loop = (with_or_for == FOR)
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.include")
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children def children
[ [
@@ -145,4 +108,6 @@ module Liquid
end end
end end
end end
Template.register_tag('include', Include)
end end
+5 -23
View File
@@ -7,45 +7,25 @@ module Liquid
# @liquid_name increment # @liquid_name increment
# @liquid_summary # @liquid_summary
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call. # Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_description # @liquid_description
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # 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 # 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) # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share # and [`capture`](/api/liquid/tags/capture). However, `increment` and [`decrement`](/api/liquid/tags/decrement) share
# variables. # variables.
# @liquid_syntax # @liquid_syntax
# {% increment variable_name %} # {% increment variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being incremented. # @liquid_syntax_keyword variable_name The name of the variable being incremented.
class Increment < Tag class Increment < Tag
include ParserSwitching
attr_reader :variable_name attr_reader :variable_name
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
@variable_name = markup.strip @variable_name = markup.strip
end end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@variable_name = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
counter_environment = context.environments.first counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0 value = counter_environment[@variable_name] || 0
@@ -55,4 +35,6 @@ module Liquid
output output
end end
end end
Template.register_tag('increment', Increment)
end end
+2
View File
@@ -25,4 +25,6 @@ module Liquid
true true
end end
end end
Template.register_tag('#', InlineComment)
end end
-54
View File
@@ -1,54 +0,0 @@
# frozen_string_literal: true
module Liquid
class Let < Block
attr_reader :to, :from, :parameters, :closure_body
def initialize(tag_name, markup, parse_context)
super
parse_context.require_closures!
parser = parse_context.new_parser(markup)
@to = parser.consume(:id)
if parser.consume?(:assign)
raise SyntaxError, "Expected let initializer" if parser.look(:end_of_string)
@from = Variable.new(parser, parse_context)
else
@parameters = parser.parameters.freeze
parser.consume(:end_of_string)
end
parse_context.declare_local(@to)
end
def parse(tokens)
return unless body_form?
@closure_body = parse_closure_body(tokens, parameters: @parameters)
end
def body_form?
@from.nil?
end
def nodelist
body_form? ? super : Const::EMPTY_ARRAY
end
def render_to_output_buffer(context, output)
value = body_form? ? @closure_body.bind(context) : @from.render(context)
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
context[@to] = value
output
end
def blank?
true
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
@node.body_form? ? [@node.closure_body] : [@node.from]
end
end
end
end
+4 -2
View File
@@ -14,6 +14,7 @@ module Liquid
# @liquid_syntax_keyword expression The expression to be output without being rendered. # @liquid_syntax_keyword expression The expression to be output without being rendered.
class Raw < Block class Raw < Block
Syntax = /\A\s*\z/ Syntax = /\A\s*\z/
FullTokenPossiblyInvalid = /\A(.*)#{TagStart}\s*(\w+)\s*(.*)?#{TagEnd}\z/om
def initialize(tag_name, markup, parse_context) def initialize(tag_name, markup, parse_context)
super super
@@ -24,8 +25,7 @@ module Liquid
def parse(tokens) def parse(tokens)
@body = +'' @body = +''
while (token = tokens.shift) while (token = tokens.shift)
if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2) if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
@body << Regexp.last_match(1) if Regexp.last_match(1) != "" @body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return return
end end
@@ -56,4 +56,6 @@ module Liquid
end end
end end
end end
Template.register_tag('raw', Raw)
end end
+26 -58
View File
@@ -6,21 +6,21 @@ module Liquid
# @liquid_category theme # @liquid_category theme
# @liquid_name render # @liquid_name render
# @liquid_summary # @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 # @liquid_description
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside # Inside snippets and app blocks, you can't directly access variables that are [created](/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) # of the snippet or app block. However, you can [specify variables as parameters](/api/liquid/tags/render#render-passing-variables-to-a-snippet)
# to pass outside variables to snippets. # to pass outside variables to snippets.
# #
# While you can't directly access created variables, you can access global objects, as well as any objects that are # While you can't directly access created variables, you can access global objects, as well as any objects that are
# directly accessible outside the snippet or app block. For example, a snippet or app block inside the [product template](/themes/architecture/templates/product) # directly accessible outside the snippet or app block. For example, a snippet or app block inside the [product template](/themes/architecture/templates/product)
# can access the [`product` object](/docs/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections) # can access the [`product` object](/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections)
# can access the [`section` object](/docs/api/liquid/objects/section). # can access the [`section` object](/api/liquid/objects/section).
# #
# Outside a snippet or app block, you can't access variables created inside the snippet or app block. # Outside a snippet or app block, you can't access variables created inside the snippet or app block.
# #
# > Note: # > Note:
# > When you render a snippet using the `render` tag, you can't use the [`include` tag](/docs/api/liquid/tags/include) # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/api/liquid/tags/include)
# > inside the snippet. # > inside the snippet.
# @liquid_syntax # @liquid_syntax
# {% render 'filename' %} # {% render 'filename' %}
@@ -35,7 +35,22 @@ module Liquid
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup)
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
end end
def for_loop? def for_loop?
@@ -54,14 +69,14 @@ module Liquid
partial = PartialCache.load( partial = PartialCache.load(
template_name, template_name,
context: context, context: context,
parse_context: parse_context, parse_context: parse_context
) )
context_variable_name = @alias_name || template_name.split('/').last context_variable_name = @alias_name || template_name.split('/').last
render_partial_func = ->(var, forloop) { render_partial_func = ->(var, forloop) {
inner_context = context.new_isolated_subcontext inner_context = context.new_isolated_subcontext
inner_context.template_name = partial.name inner_context.template_name = template_name
inner_context.partial = true inner_context.partial = true
inner_context['forloop'] = forloop if forloop inner_context['forloop'] = forloop if forloop
@@ -84,55 +99,6 @@ module Liquid
output output
end end
# render (string) (with|for expression)? (as id)? (key: value)*
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = parse_expression(strict2_template_name(p), safe: true)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = safe_parse_expression(p) if with_or_for
@alias_name = p.consume(:id) if p.id?("as")
@is_for_loop = (with_or_for == FOR)
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict2_template_name(p)
p.consume(:string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children def children
[ [
@@ -142,4 +108,6 @@ module Liquid
end end
end end
end end
Template.register_tag('render', Render)
end end
-39
View File
@@ -1,39 +0,0 @@
# frozen_string_literal: true
module Liquid
class SetTag < Tag
attr_reader :to, :from
def initialize(tag_name, markup, parse_context)
super
parse_context.require_closures!
parser = parse_context.new_parser(markup)
@to = parser.consume(:id)
parser.consume(:assign)
raise SyntaxError, "Expected set initializer" if parser.look(:end_of_string)
@from = Variable.new(parser, parse_context)
end
def render_to_output_buffer(context, output)
value = if context.variable_defined?(@to)
@from.render(context)
else
context.stack(@to => nil) { @from.render(context) }
end
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
context.set_local(@to, value)
output
end
def blank?
true
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.from]
end
end
end
end
+10 -70
View File
@@ -11,7 +11,7 @@ module Liquid
# The `tablerow` tag must be wrapped in HTML `<table>` and `</table>` tags. # The `tablerow` tag must be wrapped in HTML `<table>` and `</table>` tags.
# #
# > Tip: # > Tip:
# > Every `tablerow` loop has an associated [`tablerowloop` object](/docs/api/liquid/objects/tablerowloop) with information about the loop. # > Every `tablerow` loop has an associated [`tablerowloop` object](/api/liquid/objects/tablerowloop) with information about the loop.
# @liquid_syntax # @liquid_syntax
# {% tablerow variable in array %} # {% tablerow variable in array %}
# expression # expression
@@ -19,61 +19,17 @@ module Liquid
# @liquid_syntax_keyword variable The current item in the array. # @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over. # @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render. # @liquid_syntax_keyword expression The expression to render.
# @liquid_optional_param cols: [number] The number of columns that the table should have. # @liquid_optional_param cols [number] The number of columns that the table should have.
# @liquid_optional_param limit: [number] The number of iterations to perform. # @liquid_optional_param limit [number] The number of iterations to perform.
# @liquid_optional_param offset: [number] The 1-based index to start iterating at. # @liquid_optional_param offset [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over. # @liquid_optional_param range [untyped] A custom numeric range to iterate over.
class TableRow < Block class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
attr_reader :variable_name, :collection_name, :attributes attr_reader :variable_name, :collection_name, :attributes
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
parse_with_selected_parser(markup)
end
def parse(tokens)
parse_context.with_lexical_scope do
super
@has_local_declarations = parse_context.local_declarations?
end
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
unless p.id?("in")
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in")
end
@collection_name = safe_parse_expression(p)
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
unless ALLOWED_ATTRIBUTES.include?(key)
raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key)
end
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax if markup =~ Syntax
@variable_name = Regexp.last_match(1) @variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2)) @collection_name = parse_expression(Regexp.last_match(2))
@@ -89,13 +45,13 @@ module Liquid
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
(collection = context.evaluate(@collection_name)) || (return '') (collection = context.evaluate(@collection_name)) || (return '')
from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0 from = @attributes.key?('offset') ? context.evaluate(@attributes['offset']).to_i : 0
to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil to = @attributes.key?('limit') ? from + context.evaluate(@attributes['limit']).to_i : nil
collection = Utils.slice_collection(collection, from, to) collection = Utils.slice_collection(collection, from, to)
length = collection.length length = collection.length
cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length cols = @attributes.key?('cols') ? context.evaluate(@attributes['cols']).to_i : length
output << "<tr class=\"row1\">\n" output << "<tr class=\"row1\">\n"
context.stack do context.stack do
@@ -106,19 +62,9 @@ module Liquid
context[@variable_name] = item context[@variable_name] = item
output << "<td class=\"col#{tablerowloop.col}\">" output << "<td class=\"col#{tablerowloop.col}\">"
if @has_local_declarations super
context.stack { super }
else
super
end
output << '</td>' output << '</td>'
# Handle any interrupts if they exist.
if context.interrupt?
interrupt = context.pop_interrupt
break if interrupt.is_a?(BreakInterrupt)
end
if tablerowloop.col_last && !tablerowloop.last if tablerowloop.col_last && !tablerowloop.last
output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">" output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
end end
@@ -136,13 +82,7 @@ module Liquid
super + @node.attributes.values + [@node.collection_name] super + @node.attributes.values + [@node.collection_name]
end end
end end
private
def to_integer(value)
value.to_i
rescue NoMethodError
raise Liquid::ArgumentError, "invalid integer"
end
end end
Template.register_tag('tablerow', TableRow)
end end
+5 -3
View File
@@ -11,7 +11,7 @@ module Liquid
# Renders an expression unless a specific condition is `true`. # Renders an expression unless a specific condition is `true`.
# @liquid_description # @liquid_description
# > Tip: # > Tip:
# > Similar to the [`if` tag](/docs/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag. # > Similar to the [`if` tag](/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag.
# @liquid_syntax # @liquid_syntax
# {% unless condition %} # {% unless condition %}
# expression # expression
@@ -23,7 +23,7 @@ module Liquid
# First condition is interpreted backwards ( if not ) # First condition is interpreted backwards ( if not )
first_block = @blocks.first first_block = @blocks.first
result = Liquid::Utils.to_liquid_value( result = Liquid::Utils.to_liquid_value(
first_block.evaluate(context), first_block.evaluate(context)
) )
unless result unless result
@@ -33,7 +33,7 @@ module Liquid
# After the first condition unless works just like if # After the first condition unless works just like if
@blocks[1..-1].each do |block| @blocks[1..-1].each do |block|
result = Liquid::Utils.to_liquid_value( result = Liquid::Utils.to_liquid_value(
block.evaluate(context), block.evaluate(context)
) )
if result if result
@@ -44,4 +44,6 @@ module Liquid
output output
end end
end end
Template.register_tag('unless', Unless)
end end
+64 -79
View File
@@ -2,7 +2,7 @@
module Liquid module Liquid
# Templates are central to liquid. # Templates are central to liquid.
# Interpreting templates is a two step process. First you compile the # Interpretating templates is a two step process. First you compile the
# source code you got. During compile time some extensive error checking is performed. # source code you got. During compile time some extensive error checking is performed.
# your code should expect to get some SyntaxErrors. # your code should expect to get some SyntaxErrors.
# #
@@ -15,94 +15,98 @@ module Liquid
# template.render('user_name' => 'bob') # template.render('user_name' => 'bob')
# #
class Template class Template
attr_accessor :root, :name attr_accessor :root
attr_reader :resource_limits, :warnings attr_reader :resource_limits, :warnings
class TagRegistry
include Enumerable
def initialize
@tags = {}
@cache = {}
end
def [](tag_name)
return nil unless @tags.key?(tag_name)
return @cache[tag_name] if Liquid.cache_classes
lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o }
end
def []=(tag_name, klass)
@tags[tag_name] = klass.name
@cache[tag_name] = klass
end
def delete(tag_name)
@tags.delete(tag_name)
@cache.delete(tag_name)
end
def each(&block)
@tags.each(&block)
end
private
def lookup_class(name)
Object.const_get(name)
end
end
attr_reader :profiler attr_reader :profiler
class << self class << self
# Sets how strict the parser should be. # Sets how strict the parser should be.
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used. # :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict enforces correct syntax for most tags # :strict will enforce correct syntax.
# :strict2 enforces correct syntax for all tags attr_accessor :error_mode
def error_mode=(mode) Template.error_mode = :lax
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode attr_accessor :default_exception_renderer
Template.default_exception_renderer = lambda do |exception|
exception
end end
def error_mode attr_accessor :file_system
Environment.default.error_mode Template.file_system = BlankFileSystem.new
end
def default_exception_renderer=(renderer) attr_accessor :tags
Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=") Template.tags = TagRegistry.new
Environment.default.exception_renderer = renderer private :tags=
end
def default_exception_renderer
Environment.default.exception_renderer
end
def file_system=(file_system)
Deprecations.warn("Template.file_system=", "Environment#file_system=")
Environment.default.file_system = file_system
end
def file_system
Environment.default.file_system
end
def tags
Environment.default.tags
end
def register_tag(name, klass) def register_tag(name, klass)
Deprecations.warn("Template.register_tag", "Environment#register_tag") tags[name.to_s] = klass
Environment.default.register_tag(name, klass)
end end
# Pass a module with filter methods which should be available # Pass a module with filter methods which should be available
# to all liquid views. Good for registering the standard library # to all liquid views. Good for registering the standard library
def register_filter(mod) def register_filter(mod)
Deprecations.warn("Template.register_filter", "Environment#register_filter") StrainerFactory.add_global_filter(mod)
Environment.default.register_filter(mod)
end end
private def default_resource_limits=(limits) attr_accessor :default_resource_limits
Deprecations.warn("Template.default_resource_limits=", "Environment#default_resource_limits=") Template.default_resource_limits = {}
Environment.default.default_resource_limits = limits private :default_resource_limits=
end
def default_resource_limits
Environment.default.default_resource_limits
end
# creates a new <tt>Template</tt> object from liquid source code # creates a new <tt>Template</tt> object from liquid source code
# To enable profiling, pass in <tt>profile: true</tt> as an option. # To enable profiling, pass in <tt>profile: true</tt> as an option.
# See Liquid::Profiler for more information # See Liquid::Profiler for more information
def parse(source, options = {}) def parse(source, options = {})
environment = options[:environment] || Environment.default new.parse(source, options)
new(environment: environment).parse(source, options)
end end
end end
def initialize(environment: Environment.default) def initialize
@environment = environment
@rethrow_errors = false @rethrow_errors = false
@resource_limits = ResourceLimits.new(environment.default_resource_limits) @resource_limits = ResourceLimits.new(Template.default_resource_limits)
end end
# Parse source code. # Parse source code.
# Returns self for easy chaining # Returns self for easy chaining
def parse(source, options = {}) def parse(source, options = {})
parse_context = configure_options(options) parse_context = configure_options(options)
source = source.to_s.to_str
unless source.valid_encoding?
raise TemplateEncodingError, parse_context.locale.t("errors.syntax.invalid_template_encoding")
end
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1) tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
@root = Document.parse(tokenizer, parse_context) @root = Document.parse(tokenizer, parse_context)
self self
@@ -151,14 +155,12 @@ module Liquid
c c
when Liquid::Drop when Liquid::Drop
drop = args.shift drop = args.shift
c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
drop.context = c if drop.respond_to?(:context=)
c
when Hash when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
when nil when nil
Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits)
else else
raise ArgumentError, "Expected Hash or Liquid::Context as parameter" raise ArgumentError, "Expected Hash or Liquid::Context as parameter"
end end
@@ -187,22 +189,12 @@ module Liquid
@profiler = context.profiler = Liquid::Profiler.new @profiler = context.profiler = Liquid::Profiler.new
end end
context.template_name ||= name
previous_error_mode = context.registers.static[:template_error_mode]
context.registers.static[:template_error_mode] = @error_mode
begin begin
# render the nodelist. # render the nodelist.
@root.render_to_output_buffer(context, output || +'') @root.render_to_output_buffer(context, output || +'')
rescue Liquid::MemoryError => e rescue Liquid::MemoryError => e
context.handle_error(e) context.handle_error(e)
ensure ensure
if previous_error_mode
context.registers.static[:template_error_mode] = previous_error_mode
else
context.registers.static.delete(:template_error_mode)
end
@errors = context.errors @errors = context.errors
end end
end end
@@ -226,15 +218,8 @@ module Liquid
@options = options @options = options
@profiling = profiling @profiling = profiling
@line_numbers = options[:line_numbers] || @profiling @line_numbers = options[:line_numbers] || @profiling
parse_context = if options.is_a?(ParseContext) parse_context = options.is_a?(ParseContext) ? options : ParseContext.new(options)
options @warnings = parse_context.warnings
else
opts = options.key?(:environment) ? options : options.merge(environment: @environment)
ParseContext.new(opts)
end
@warnings = parse_context.warnings
@error_mode = parse_context.error_mode
parse_context parse_context
end end
+16 -132
View File
@@ -1,43 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
require "strscan"
module Liquid module Liquid
class Tokenizer class Tokenizer
attr_reader :line_number, :for_liquid_tag attr_reader :line_number, :for_liquid_tag
TAG_END = /%\}/ def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false)
TAG_OR_VARIABLE_START = /\{[\{\%]/ @source = source.to_s.to_str
NEWLINE = /\n/ @line_number = line_number || (line_numbers ? 1 : nil)
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 @for_liquid_tag = for_liquid_tag
@source = source.to_s.to_str @offset = 0
@offset = 0 @tokens = tokenize
@tokens = []
if @source
@ss = string_scanner
@ss.string = @source
tokenize
end
end end
def shift def shift
token = @tokens[@offset] token = @tokens[@offset]
return nil unless token
return unless token
@offset += 1 @offset += 1
@@ -51,111 +28,18 @@ module Liquid
private private
def tokenize def tokenize
if @for_liquid_tag return [] if @source.empty?
@tokens = @source.split("\n")
else return @source.split("\n") if @for_liquid_tag
@tokens << shift_normal until @ss.eos?
tokens = @source.split(TemplateParser)
# removes the rogue empty element at the beginning of the array
if tokens[0]&.empty?
@offset += 1
end end
@source = nil tokens
@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 end
end end
+3 -104
View File
@@ -2,9 +2,6 @@
module Liquid module Liquid
module Utils module Utils
DECIMAL_REGEX = /\A-?\d+\.\d+\z/
UNIX_TIMESTAMP_REGEX = /\A\d+\z/
def self.slice_collection(collection, from, to) def self.slice_collection(collection, from, to)
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice) if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
collection.load_slice(from, to) collection.load_slice(from, to)
@@ -55,7 +52,7 @@ module Liquid
when Numeric when Numeric
obj obj
when String when String
DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i /\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
else else
if obj.respond_to?(:to_number) if obj.respond_to?(:to_number)
obj.to_number obj.to_number
@@ -69,14 +66,14 @@ module Liquid
return obj if obj.respond_to?(:strftime) return obj if obj.respond_to?(:strftime)
if obj.is_a?(String) if obj.is_a?(String)
return if obj.empty? return nil if obj.empty?
obj = obj.downcase obj = obj.downcase
end end
case obj case obj
when 'now', 'today' when 'now', 'today'
Time.now Time.now
when UNIX_TIMESTAMP_REGEX, Integer when /\A\d+\z/, Integer
Time.at(obj.to_i) Time.at(obj.to_i)
when String when String
Time.parse(obj) Time.parse(obj)
@@ -92,103 +89,5 @@ module Liquid
# Otherwise return the object itself # Otherwise return the object itself
obj obj
end end
def self.to_s(obj, seen = {})
case obj
when BigDecimal
obj.to_s("F")
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
end end
+14 -80
View File
@@ -25,7 +25,7 @@ module Liquid
include ParserSwitching include ParserSwitching
def initialize(markup, parse_context) def initialize(markup, parse_context)
@markup = markup.is_a?(Parser) ? markup.remaining_markup : markup @markup = markup
@name = nil @name = nil
@parse_context = parse_context @parse_context = parse_context
@line_number = parse_context.line_number @line_number = parse_context.line_number
@@ -37,12 +37,7 @@ module Liquid
@markup @markup
end end
def ==(other)
self.class == other.class && name == other.name && filters == other.filters
end
def markup_context(markup) def markup_context(markup)
markup = @markup if markup.is_a?(Parser)
"in \"{{#{markup}}}\"" "in \"{{#{markup}}}\""
end end
@@ -59,37 +54,26 @@ module Liquid
next unless f =~ /\w+/ next unless f =~ /\w+/
filtername = Regexp.last_match(0) filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten filterargs = f.scan(FilterArgsRegex).flatten
@filters << lax_parse_filter_expressions(filtername, filterargs) @filters << parse_filter_expressions(filtername, filterargs)
end end
end end
end end
def strict_parse(markup) def strict_parse(markup)
@filters = [] @filters = []
p = @parse_context.new_parser(markup) p = Parser.new(markup)
return if p.look(:end_of_string) return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p) @name = parse_context.parse_expression(p.expression)
while p.consume?(:pipe) while p.consume?(:pipe)
filtername = p.consume(:id) filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY filterargs = p.consume?(:colon) ? parse_filterargs(p) : []
@filters << lax_parse_filter_expressions(filtername, filterargs) @filters << parse_filter_expressions(filtername, filterargs)
end end
p.consume(:end_of_string) p.consume(:end_of_string)
end end
def strict2_parse(markup)
@filters = []
p = markup.is_a?(Parser) ? markup : @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
@filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
def parse_filterargs(p) def parse_filterargs(p)
# first argument # first argument
filterargs = [p.argument] filterargs = [p.argument]
@@ -110,26 +94,16 @@ module Liquid
end end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
if @name.is_a?(CallExpression) && @filters.empty? && context.global_filter.nil?
return @name.render_to_output_buffer(context, output)
end
obj = render(context) obj = render(context)
render_obj_to_output(obj, output)
output
end
def render_obj_to_output(obj, output) if obj.is_a?(Array)
case obj output << obj.join
when NilClass elsif obj.nil?
# Do nothing
when Array
obj.each do |o|
render_obj_to_output(o, output)
end
else else
output << Liquid::Utils.to_s(obj) output << obj.to_s
end end
output
end end
def disabled?(_context) def disabled?(_context)
@@ -142,7 +116,7 @@ module Liquid
private private
def lax_parse_filter_expressions(filter_name, unparsed_args) def parse_filter_expressions(filter_name, unparsed_args)
filter_args = [] filter_args = []
keyword_args = nil keyword_args = nil
unparsed_args.each do |a| unparsed_args.each do |a|
@@ -158,46 +132,6 @@ module Liquid
result result
end end
# Surprisingly, positional and keyword arguments can be mixed.
#
# filter = filtername [":" filterargs?]
# filterargs = argument ("," argument)*
# argument = (positional_argument | keyword_argument)
# positional_argument = expression
# keyword_argument = id ":" expression
def strict2_parse_filter_expressions(p)
filtername = p.consume(:id)
filter_args = []
keyword_args = {}
if p.consume?(:colon)
# Parse first argument (no leading comma)
argument(p, filter_args, keyword_args) unless end_of_arguments?(p)
# Parse remaining arguments (with leading commas) and optional trailing comma
argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p)
end
result = [filtername, filter_args]
result << keyword_args unless keyword_args.empty?
result
end
def argument(p, positional_arguments, keyword_arguments)
if p.look(:id) && p.look(:colon, 1)
key = p.consume(:id)
p.consume(:colon)
value = parse_context.safe_parse_expression(p)
keyword_arguments[key] = value
else
positional_arguments << parse_context.safe_parse_expression(p)
end
end
def end_of_arguments?(p)
p.look(:pipe) || p.look(:end_of_string)
end
def evaluate_filter_expressions(context, filter_args, filter_kwargs) def evaluate_filter_expressions(context, filter_args, filter_kwargs)
parsed_args = filter_args.map { |expr| context.evaluate(expr) } parsed_args = filter_args.map { |expr| context.evaluate(expr) }
if filter_kwargs if filter_kwargs
@@ -212,7 +146,7 @@ module Liquid
class ParseTreeVisitor < Liquid::ParseTreeVisitor class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children def children
[@node.name] + @node.filters.flatten.flat_map { |value| value.is_a?(Hash) ? value.values : value } [@node.name] + @node.filters.flatten
end end
end end
end end
+5 -24
View File
@@ -6,20 +6,16 @@ module Liquid
attr_reader :name, :lookups attr_reader :name, :lookups
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil) def self.parse(markup)
new(markup, string_scanner, cache) new(markup)
end end
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil) def initialize(markup)
lookups = markup.scan(VariableParser) lookups = markup.scan(VariableParser)
name = lookups.shift name = lookups.shift
if name&.start_with?('[') && name&.end_with?(']') if name&.start_with?('[') && name&.end_with?(']')
name = Expression.parse( name = Expression.parse(name[1..-2])
name[1..-2],
string_scanner,
cache,
)
end end
@name = name @name = name
@@ -29,23 +25,13 @@ module Liquid
@lookups.each_index do |i| @lookups.each_index do |i|
lookup = lookups[i] lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']') if lookup&.start_with?('[') && lookup&.end_with?(']')
lookups[i] = Expression.parse( lookups[i] = Expression.parse(lookup[1..-2])
lookup[1..-2],
string_scanner,
cache,
)
elsif COMMAND_METHODS.include?(lookup) elsif COMMAND_METHODS.include?(lookup)
@command_flags |= 1 << i @command_flags |= 1 << i
end end
end end
end end
# @api private
def add_lookup(key, command: false)
@command_flags |= 1 << @lookups.length if command && COMMAND_METHODS.include?(key)
@lookups << key
end
def lookup_command?(lookup_index) def lookup_command?(lookup_index)
@command_flags & (1 << lookup_index) != 0 @command_flags & (1 << lookup_index) != 0
end end
@@ -76,11 +62,6 @@ module Liquid
elsif lookup_command?(i) && object.respond_to?(key) elsif lookup_command?(i) && object.respond_to?(key)
object = object.send(key).to_liquid object = object.send(key).to_liquid
# Handle string first/last like ActiveSupport does (returns first/last character)
# ActiveSupport returns "" for empty strings, not nil
elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last")
object = key == "first" ? (object[0] || "") : (object[-1] || "")
# No key was present with the desired value and it wasn't one of the directly supported # No key was present with the desired value and it wasn't one of the directly supported
# keywords either. The only thing we got left is to return nil or # keywords either. The only thing we got left is to return nil or
# raise an exception if `strict_variables` option is set to true # raise an exception if `strict_variables` option is set to true
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Liquid module Liquid
VERSION = "5.13.0" VERSION = "5.4.0"
end end
+2 -5
View File
@@ -13,11 +13,11 @@ Gem::Specification.new do |s|
s.summary = "A secure, non-evaling end user template engine with aesthetic markup." s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
s.authors = ["Tobias Lütke"] s.authors = ["Tobias Lütke"]
s.email = ["[email protected]"] s.email = ["[email protected]"]
s.homepage = "https://shopify.github.io/liquid/" s.homepage = "http://www.liquidmarkup.org"
s.license = "MIT" s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup." # s.description = "A secure, non-evaling end user template engine with aesthetic markup."
s.required_ruby_version = ">= 3.0.0" s.required_ruby_version = ">= 2.7.0"
s.required_rubygems_version = ">= 1.3.7" s.required_rubygems_version = ">= 1.3.7"
s.metadata['allowed_push_host'] = 'https://rubygems.org' s.metadata['allowed_push_host'] = 'https://rubygems.org'
@@ -28,9 +28,6 @@ Gem::Specification.new do |s|
s.require_path = "lib" s.require_path = "lib"
s.add_dependency("strscan", ">= 3.1.1")
s.add_dependency("bigdecimal")
s.add_development_dependency('rake', '~> 13.0') s.add_development_dependency('rake', '~> 13.0')
s.add_development_dependency('minitest') s.add_development_dependency('minitest')
end end
+6 -11
View File
@@ -3,23 +3,18 @@
require 'benchmark/ips' require 'benchmark/ips'
require_relative 'theme_runner' require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT) Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new profiler = ThemeRunner.new
Benchmark.ips do |x| Benchmark.ips do |x|
x.time = 20 x.time = 10
x.warmup = 10 x.warmup = 5
puts puts
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)." puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
puts puts
phase = ENV["PHASE"] || "all" x.report("parse:") { profiler.compile }
x.report("render:") { profiler.render }
x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize" x.report("parse & render:") { profiler.run }
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 end
+5
View File
@@ -49,3 +49,8 @@ module Database
end end
end end
end end
if __FILE__ == $PROGRAM_NAME
p(Database.tables['collections']['frontpage'].keys)
# p Database.tables['blog']['articles']
end
+7 -8
View File
@@ -11,12 +11,11 @@ require_relative 'shop_filter'
require_relative 'tag_filter' require_relative 'tag_filter'
require_relative 'weight_filter' require_relative 'weight_filter'
default_environment = Liquid::Environment.default Liquid::Template.register_tag('paginate', Paginate)
default_environment.register_tag('paginate', Paginate) Liquid::Template.register_tag('form', CommentForm)
default_environment.register_tag('form', CommentForm)
default_environment.register_filter(JsonFilter) Liquid::Template.register_filter(JsonFilter)
default_environment.register_filter(MoneyFilter) Liquid::Template.register_filter(MoneyFilter)
default_environment.register_filter(WeightFilter) Liquid::Template.register_filter(WeightFilter)
default_environment.register_filter(ShopFilter) Liquid::Template.register_filter(ShopFilter)
default_environment.register_filter(TagFilter) Liquid::Template.register_filter(TagFilter)
+2 -2
View File
@@ -45,8 +45,8 @@ class Paginate < Liquid::Block
pagination['items'] = collection_size pagination['items'] = collection_size
pagination['pages'] = page_count - 1 pagination['pages'] = page_count - 1
pagination['previous'] = link('&laquo; Previous', current_page - 1) if 1 < current_page pagination['previous'] = link('&laquo; Previous', current_page - 1) unless 1 >= current_page
pagination['next'] = link('Next &raquo;', current_page + 1) if page_count > current_page + 1 pagination['next'] = link('Next &raquo;', current_page + 1) unless page_count <= current_page + 1
pagination['parts'] = [] pagination['parts'] = []
hellip_break = false hellip_break = false
-13
View File
@@ -48,19 +48,6 @@ class ThemeRunner
end end
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 # `run` is called to benchmark rendering and compiling at the same time
def run def run
each_test do |liquid, layout, assigns, page_template, template_name| each_test do |liquid, layout, assigns, page_template, template_name|
-94
View File
@@ -1,94 +0,0 @@
# 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
-43
View File
@@ -1,43 +0,0 @@
# frozen_string_literal: true
require "benchmark/ips"
# benchmark liquid lexing
require 'liquid'
RubyVM::YJIT.enable
EXPRESSIONS = [
"foo[1..2].baz",
"12.0",
"foo.bar.based",
"21 - 62",
"foo.bar.baz",
"foo > 12",
"foo < 12",
"foo <= 12",
"foo >= 12",
"foo <> 12",
"foo == 12",
"foo != 12",
"foo contains 12",
"foo contains 'bar'",
"foo != 'bar'",
"'foo' contains 'bar'",
'234089',
"foo | default: -1",
]
Benchmark.ips do |x|
x.config(time: 10, warmup: 5)
x.report("Liquid::Lexer#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer.new(expr)
l.tokenize
end
end
x.compare!
end
-49
View File
@@ -1,49 +0,0 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation)
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:activesupport,
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
# @param ctx [Hash] adapter context containing :template
# @param assigns [Hash] environment variables
# @param options [Hash] :registers, :strict_errors, :exception_renderer
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
-46
View File
@@ -1,46 +0,0 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with lax parsing mode
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:activesupport,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Default to lax mode while still honoring specs that explicitly set error_mode.
options = { error_mode: :lax }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
-49
View File
@@ -1,49 +0,0 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
# @param ctx [Hash] adapter context containing :template
# @param assigns [Hash] environment variables
# @param options [Hash] :registers, :strict_errors, :exception_renderer
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
-53
View File
@@ -1,53 +0,0 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
# Enable YJIT if available
if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
RubyVM::YJIT.enable
end
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force strict mode
options = { error_mode: :strict }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
@@ -1,24 +0,0 @@
# frozen_string_literal: true
module LiquidSpecAdapterHelper
extend self
def resource_limits(render_options)
return unless render_options[:resource_limits]
Liquid::ResourceLimits.new({}).tap do |limits|
render_options[:resource_limits].each do |key, value|
limits.public_send(:"#{key}=", value)
end
end
end
def with_frozen_time(&block)
original_tz = ENV['TZ']
ENV['TZ'] = 'UTC'
Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block)
ensure
ENV['TZ'] = original_tz
end
end
+8 -57
View File
@@ -14,25 +14,19 @@ class AssignTest < Minitest::Test
end end
def test_assigned_variable def test_assigned_variable
assert_template_result( assert_template_result('.foo.',
'.foo.',
'{% assign foo = values %}.{{ foo[0] }}.', '{% assign foo = values %}.{{ foo[0] }}.',
{ 'values' => %w(foo bar baz) }, { 'values' => %w(foo bar baz) })
)
assert_template_result( assert_template_result('.bar.',
'.bar.',
'{% assign foo = values %}.{{ foo[1] }}.', '{% assign foo = values %}.{{ foo[1] }}.',
{ 'values' => %w(foo bar baz) }, { 'values' => %w(foo bar baz) })
)
end end
def test_assign_with_filter def test_assign_with_filter
assert_template_result( assert_template_result('.bar.',
'.bar.',
'{% assign foo = values | split: "," %}.{{ foo[1] }}.', '{% assign foo = values | split: "," %}.{{ foo[1] }}.',
{ 'values' => "foo,bar,baz" }, { 'values' => "foo,bar,baz" })
)
end end
def test_assign_syntax_error def test_assign_syntax_error
@@ -40,11 +34,8 @@ class AssignTest < Minitest::Test
end end
def test_assign_uses_error_mode def test_assign_uses_error_mode
assert_match_syntax_error( assert_match_syntax_error("Expected dotdot but found pipe in ",
"Expected dotdot but found pipe in ", "{% assign foo = ('X' | downcase) %}", error_mode: :strict)
"{% assign foo = ('X' | downcase) %}",
error_mode: :strict,
)
assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax) assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax)
end end
@@ -97,46 +88,6 @@ class AssignTest < Minitest::Test
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd')) assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
end end
def test_assign_with_valid_identifier_in_strict2
assert_template_result("hello", "{% assign my_var = 'hello' %}{{ my_var }}", error_mode: :strict2)
end
def test_assign_with_hyphen_in_strict2
assert_template_result("hello", "{% assign my-var = 'hello' %}{{ my-var }}", error_mode: :strict2)
end
def test_assign_rejects_parentheses_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign (a(b(c) = 1234 %}", error_mode: :strict2)
end
end
def test_assign_rejects_brackets_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign [x.y] = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_rejects_dot_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign a.b = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_rejects_numeric_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign 1abc = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_allows_invalid_names_in_lax
assert_template_result("1234", "{% assign (a(b(c) = 1234 %}{{ self['(a(b(c)'] }}", error_mode: :lax)
end
def test_assign_with_filter_in_strict2
assert_template_result("HELLO", "{% assign my_var = 'hello' | upcase %}{{ my_var }}", error_mode: :strict2)
end
private private
class ObjectWrapperDrop < Liquid::Drop class ObjectWrapperDrop < Liquid::Drop
@@ -1,85 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class BlankBodyErrorHandlingTest < Minitest::Test
COMPARISON_ERROR = 'Liquid error (line 1): comparison of Integer with String failed'
INVALID_INTEGER_ERROR = 'Liquid error (line 1): invalid integer'
def render_inline(source, error_mode:, assigns: {})
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render(assigns, render_errors: true)
end
def assert_render_raises(source, error_mode:, assigns: {}, message: nil)
error = assert_raises(Liquid::ArgumentError) do
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns)
end
assert_includes(error.message, message) if message
end
def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal('', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode))
end
end
def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal('', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode))
end
end
def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal('', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }))
end
end
def test_strict2_blank_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2))
end
def test_strict2_whitespace_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2))
end
def test_strict2_assign_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2))
end
def test_strict2_comment_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2))
end
def test_strict2_capture_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2))
end
def test_strict2_blank_unless_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2))
end
def test_strict2_blank_for_body_shows_inline_error_text
assert_equal(INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }))
end
def test_nonblank_bodies_show_inline_error_text_in_all_modes
[:lax, :strict, :strict2].each do |mode|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode))
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode))
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode))
end
end
def test_raised_errors_are_not_swallowed_by_blank_if_body
[:lax, :strict, :strict2].each do |mode|
assert_render_raises('{% if 5 > "x" %}{% endif %}', error_mode: mode, message: 'comparison of Integer with String failed')
end
end
def test_raised_errors_are_not_swallowed_by_blank_for_body
[:lax, :strict, :strict2].each do |mode|
assert_render_raises('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }, message: 'invalid integer')
end
end
end
+8 -19
View File
@@ -57,11 +57,9 @@ class BlankTest < Minitest::Test
def test_nested_blocks_are_blank_but_only_if_all_children_are def test_nested_blocks_are_blank_but_only_if_all_children_are
assert_template_result("", wrap(wrap(" "))) assert_template_result("", wrap(wrap(" ")))
assert_template_result( assert_template_result("\n but this is not " * (N + 1),
"\n but this is not " * (N + 1),
wrap('{% if true %} {% comment %} this is blank {% endcomment %} {% endif %} wrap('{% if true %} {% comment %} this is blank {% endcomment %} {% endif %}
{% if true %} but this is not {% endif %}'), {% if true %} but this is not {% endif %}'))
)
end end
def test_assigns_are_blank def test_assigns_are_blank
@@ -91,21 +89,12 @@ class BlankTest < Minitest::Test
end end
def test_include_is_blank def test_include_is_blank
assert_template_result( assert_template_result("foobar" * (N + 1), wrap("{% include 'foobar' %}"),
"foobar" * (N + 1), partials: { 'foobar' => 'foobar' })
wrap("{% include 'foobar' %}"), assert_template_result(" foobar " * (N + 1), wrap("{% include ' foobar ' %}"),
partials: { 'foobar' => 'foobar' }, partials: { ' foobar ' => ' foobar ' })
) assert_template_result(" " * (N + 1), wrap(" {% include ' ' %} "),
assert_template_result( partials: { ' ' => ' ' })
" foobar " * (N + 1),
wrap("{% include ' foobar ' %}"),
partials: { ' foobar ' => ' foobar ' },
)
assert_template_result(
" " * (N + 1),
wrap(" {% include ' ' %} "),
partials: { ' ' => ' ' },
)
end end
def test_case_is_blank def test_case_is_blank
+1 -36
View File
@@ -6,11 +6,7 @@ class CaptureTest < Minitest::Test
include Liquid include Liquid
def test_captures_block_content_in_variable def test_captures_block_content_in_variable
assert_template_result("test string", "{% capture var %}test string{% endcapture %}{{var}}", {}) assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {})
end
def test_captures_block_content_in_quoted_variable_in_lax
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}, error_mode: :lax)
end end
def test_capture_with_hyphen_in_variable_name def test_capture_with_hyphen_in_variable_name
@@ -53,35 +49,4 @@ class CaptureTest < Minitest::Test
t.render! t.render!
assert_equal(9, t.resource_limits.assign_score) assert_equal(9, t.resource_limits.assign_score)
end end
def test_capture_with_valid_identifier_in_strict2
assert_template_result("hello", "{% capture my_var %}hello{% endcapture %}{{ my_var }}", error_mode: :strict2)
end
def test_capture_with_hyphen_in_strict2
assert_template_result("hello", "{% capture my-var %}hello{% endcapture %}{{ my-var }}", error_mode: :strict2)
end
def test_capture_rejects_parentheses_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_rejects_dot_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture a.b %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_rejects_numeric_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture 1abc %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_allows_invalid_names_in_lax
t = Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :lax)
assert_equal("(x[y", t.root.nodelist.first.to)
end
end end
+25 -106
View File
@@ -36,24 +36,6 @@ class Category
end end
end end
class ProductsDrop < Liquid::Drop
def initialize(products)
@products = products
end
def size
@products.size
end
def to_liquid
if @context["forloop"]
@products.first(@context["forloop"].length)
else
@products
end
end
end
class CategoryDrop < Liquid::Drop class CategoryDrop < Liquid::Drop
attr_accessor :category, :context attr_accessor :category, :context
@@ -139,23 +121,14 @@ class ContextTest < Minitest::Test
end end
def test_length_query def test_length_query
assert_template_result( assert_template_result("true", "{% if numbers.size == 4 %}true{% endif %}",
"true", { "numbers" => [1, 2, 3, 4] })
"{% if numbers.size == 4 %}true{% endif %}",
{ "numbers" => [1, 2, 3, 4] },
)
assert_template_result( assert_template_result("true", "{% if numbers.size == 4 %}true{% endif %}",
"true", { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } })
"{% if numbers.size == 4 %}true{% endif %}",
{ "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } },
)
assert_template_result( assert_template_result("true", "{% if numbers.size == 1000 %}true{% endif %}",
"true", { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } })
"{% if numbers.size == 1000 %}true{% endif %}",
{ "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } },
)
end end
def test_hyphenated_variable def test_hyphenated_variable
@@ -255,14 +228,12 @@ class ContextTest < Minitest::Test
end end
def test_hash_to_array_transition def test_hash_to_array_transition
assigns = { assigns = { 'colors' => {
'colors' => { 'Blue' => ['003366', '336699', '6699CC', '99CCFF'],
'Blue' => ['003366', '336699', '6699CC', '99CCFF'], 'Green' => ['003300', '336633', '669966', '99CC99'],
'Green' => ['003300', '336633', '669966', '99CC99'], 'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'],
'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'], 'Red' => ['660000', '993333', 'CC6666', 'FF9999'],
'Red' => ['660000', '993333', 'CC6666', 'FF9999'], } }
},
}
assert_template_result("003366", "{{ colors.Blue[0] }}", assigns) assert_template_result("003366", "{{ colors.Blue[0] }}", assigns)
assert_template_result("FF9999", "{{ colors.Red[3] }}", assigns) assert_template_result("FF9999", "{{ colors.Red[3] }}", assigns)
@@ -291,13 +262,13 @@ class ContextTest < Minitest::Test
assigns = { 'product' => { 'variants' => [{ 'title' => 'draft151cm' }, { 'title' => 'element151cm' }] } } assigns = { 'product' => { 'variants' => [{ 'title' => 'draft151cm' }, { 'title' => 'element151cm' }] } }
assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns) assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns)
assert_template_result("element151cm", '{{ product["variants"][1]["title"] }}', assigns) assert_template_result("element151cm", '{{ product["variants"][1]["title"] }}', assigns)
assert_template_result("draft151cm", '{{ product["variants"].first["title"] }}', assigns) assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns)
assert_template_result("element151cm", '{{ product["variants"].last["title"] }}', assigns) assert_template_result("element151cm", '{{ product["variants"].last["title"] }}', assigns)
end end
def test_access_variable_with_hash_notation def test_access_variable_with_hash_notation
assert_template_result('baz', '{{ foo }}', { "foo" => "baz" }) assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' }) assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
end end
def test_access_hashes_with_hash_access_variables def test_access_hashes_with_hash_access_variables
@@ -439,12 +410,10 @@ class ContextTest < Minitest::Test
def test_nested_lambda_is_called_once def test_nested_lambda_is_called_once
@global = 0 @global = 0
@context['callcount'] = { @context['callcount'] = { "lambda" => proc {
"lambda" => proc { @global += 1
@global += 1 @global.to_s
@global.to_s } }
},
}
assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda'])
assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda'])
@@ -454,11 +423,10 @@ class ContextTest < Minitest::Test
def test_lambda_in_array_is_called_once def test_lambda_in_array_is_called_once
@global = 0 @global = 0
p = proc { @context['callcount'] = [1, 2, proc {
@global += 1 @global += 1
@global.to_s @global.to_s
} }, 4, 5]
@context['callcount'] = [1, 2, p, 4, 5]
assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]'])
assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]'])
@@ -505,7 +473,7 @@ class ContextTest < Minitest::Test
def test_static_environments_are_read_with_lower_priority_than_environments def test_static_environments_are_read_with_lower_priority_than_environments
context = Context.build( context = Context.build(
static_environments: { 'shadowed' => 'static', 'unshadowed' => 'static' }, static_environments: { 'shadowed' => 'static', 'unshadowed' => 'static' },
environments: { 'shadowed' => 'dynamic' }, environments: { 'shadowed' => 'dynamic' }
) )
assert_equal('dynamic', context['shadowed']) assert_equal('dynamic', context['shadowed'])
@@ -632,28 +600,13 @@ class ContextTest < Minitest::Test
end end
def test_has_key_will_not_add_an_error_for_missing_keys def test_has_key_will_not_add_an_error_for_missing_keys
with_error_modes(:strict) do with_error_mode(:strict) do
context = Context.new context = Context.new
context.key?('unknown') context.key?('unknown')
assert_empty(context.errors) assert_empty(context.errors)
end end
end end
def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled
context = Context.new
context.strict_variables = true
assert_raises(Liquid::UndefinedVariable) do
context['unknown']
end
end
def test_has_key_will_not_raise_for_missing_keys_when_strict_variables_is_enabled
context = Context.new
context.strict_variables = true
refute(context.key?('unknown'))
assert_empty(context.errors)
end
def test_context_always_uses_static_registers def test_context_always_uses_static_registers
registers = { registers = {
my_register: :my_value, my_register: :my_value,
@@ -668,40 +621,6 @@ class ContextTest < Minitest::Test
assert_equal(:my_value, c.registers[:my_register]) assert_equal(:my_value, c.registers[:my_register])
end end
def test_variable_to_liquid_returns_contextual_drop
context = {
"products" => ProductsDrop.new(["A", "B", "C", "D", "E"]),
}
template = Liquid::Template.parse(<<~LIQUID)
{%- for i in (1..3) -%}
for_loop_products_count: {{ products | size }}
{% endfor %}
unscoped_products_count: {{ products | size }}
LIQUID
result = template.render(context)
assert_includes(result, "for_loop_products_count: 3")
assert_includes(result, "unscoped_products_count: 5")
end
def test_new_isolated_context_inherits_parent_environment
global_environment = Liquid::Environment.build(tags: {})
context = Context.build(environment: global_environment)
subcontext = context.new_isolated_subcontext
assert_equal(global_environment, subcontext.environment)
end
def test_newly_built_context_inherits_parent_environment
global_environment = Liquid::Environment.build(tags: {})
context = Context.build(environment: global_environment)
assert_equal(global_environment, context.environment)
assert(context.environment.tags.each.to_a.empty?)
end
private private
def assert_no_object_allocations def assert_no_object_allocations
+23 -117
View File
@@ -67,7 +67,7 @@ class ErrorHandlingTest < Minitest::Test
end end
def test_unrecognized_operator def test_unrecognized_operator
with_error_modes(:strict) do with_error_mode(:strict) do
assert_raises(SyntaxError) do assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ') Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end end
@@ -105,8 +105,7 @@ class ErrorHandlingTest < Minitest::Test
end end
def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
template = Liquid::Template.parse( template = Liquid::Template.parse('
'
foobar foobar
{% if 1 =! 2 %}ok{% endif %} {% if 1 =! 2 %}ok{% endif %}
@@ -114,19 +113,15 @@ class ErrorHandlingTest < Minitest::Test
bla bla
', ',
error_mode: :warn, error_mode: :warn,
line_numbers: true, line_numbers: true)
)
assert_equal( assert_equal(['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'],
['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'], template.warnings.map(&:message))
template.warnings.map(&:message),
)
end end
def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors
err = assert_raises(SyntaxError) do err = assert_raises(SyntaxError) do
Liquid::Template.parse( Liquid::Template.parse('
'
foobar foobar
{% if 1 =! 2 %}ok{% endif %} {% if 1 =! 2 %}ok{% endif %}
@@ -134,8 +129,7 @@ class ErrorHandlingTest < Minitest::Test
bla bla
', ',
error_mode: :strict, error_mode: :strict,
line_numbers: true, line_numbers: true)
)
end end
assert_equal('Liquid syntax error (line 4): Unexpected character = in "1 =! 2"', err.message) assert_equal('Liquid syntax error (line 4): Unexpected character = in "1 =! 2"', err.message)
@@ -203,34 +197,20 @@ class ErrorHandlingTest < Minitest::Test
end end
def test_setting_default_exception_renderer def test_setting_default_exception_renderer
old_exception_renderer = Liquid::Template.default_exception_renderer
exceptions = [] exceptions = []
default_exception_renderer = ->(e) { Liquid::Template.default_exception_renderer = ->(e) {
exceptions << e exceptions << e
'' ''
} }
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}')
env = Liquid::Environment.build(exception_renderer: default_exception_renderer)
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: env)
output = template.render('errors' => ErrorDrop.new) output = template.render('errors' => ErrorDrop.new)
assert_equal('This is a runtime error: ', output) assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
end ensure
Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer
def test_setting_exception_renderer_on_environment
exceptions = []
exception_renderer = ->(e) do
exceptions << e
''
end
environment = Liquid::Environment.build(exception_renderer: exception_renderer)
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: environment)
output = template.render('errors' => ErrorDrop.new)
assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
end end
def test_exception_renderer_exposing_non_liquid_error def test_exception_renderer_exposing_non_liquid_error
@@ -256,10 +236,16 @@ class ErrorHandlingTest < Minitest::Test
end end
def test_included_template_name_with_line_numbers def test_included_template_name_with_line_numbers
environment = Liquid::Environment.build(file_system: TestFileSystem.new) old_file_system = Liquid::Template.file_system
template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true, environment: environment)
page = template.render('errors' => ErrorDrop.new)
begin
Liquid::Template.file_system = TestFileSystem.new
template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true)
page = template.render('errors' => ErrorDrop.new)
ensure
Liquid::Template.file_system = old_file_system
end
assert_equal("Argument error:\nLiquid error (product line 1): argument error", page) assert_equal("Argument error:\nLiquid error (product line 1): argument error", page)
assert_equal("product", template.errors.first.template_name) assert_equal("product", template.errors.first.template_name)
end end
@@ -268,87 +254,7 @@ class ErrorHandlingTest < Minitest::Test
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render
assert_equal("Liquid error: comparison of Integer with String failed0", output) assert_equal("Liquid error: comparison of Integer with String failed0", output)
source = "{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}" output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
[:lax, :warn, :strict].each do |mode| assert_equal("0", output)
assert_equal("0", Liquid::Template.parse(source, error_mode: mode).render)
end
assert_equal("Liquid error: comparison of Integer with String failed0", Liquid::Template.parse(source, error_mode: :strict2).render)
end
def test_syntax_error_is_raised_with_template_name
file_system = StubFileSystem.new("snippet" => "1\n2\n{{ 1")
context = Liquid::Context.build(
registers: { file_system: file_system },
)
template = Template.parse(
'{% render "snippet" %}',
line_numbers: true,
)
template.name = "template/index"
assert_equal(
"Liquid syntax error (snippet line 3): Variable '{{' was not properly terminated with regexp: /\\}\\}/",
template.render(context),
)
end
def test_syntax_error_is_raised_with_template_name_from_template_factory
file_system = StubFileSystem.new("snippet" => "1\n2\n{{ 1")
context = Liquid::Context.build(
registers: {
file_system: file_system,
template_factory: StubTemplateFactory.new,
},
)
template = Template.parse(
'{% render "snippet" %}',
line_numbers: true,
)
template.name = "template/index"
assert_equal(
"Liquid syntax error (some/path/snippet line 3): Variable '{{' was not properly terminated with regexp: /\\}\\}/",
template.render(context),
)
end
def test_error_is_raised_during_parse_with_template_name
depth = Liquid::Block::MAX_DEPTH + 1
code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
template = Template.parse("{% render 'snippet' %}", line_numbers: true)
context = Liquid::Context.build(
registers: {
file_system: StubFileSystem.new("snippet" => code),
template_factory: StubTemplateFactory.new,
},
)
assert_equal("Liquid error (some/path/snippet line 1): Nesting too deep", template.render(context))
end
def test_internal_error_is_raised_with_template_name
template = Template.new
template.parse(
"{% render 'snippet' %}",
line_numbers: true,
)
template.name = "template/index"
context = Liquid::Context.build(
registers: {
file_system: StubFileSystem.new({}),
},
)
assert_equal(
"Liquid error (template/index line 1): internal",
template.render(context),
)
end end
end end
+2 -143
View File
@@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'test_helper' require 'test_helper'
require 'lru_redux'
class ExpressionTest < Minitest::Test class ExpressionTest < Minitest::Test
def test_keyword_literals def test_keyword_literals
@@ -14,7 +13,6 @@ class ExpressionTest < Minitest::Test
assert_template_result("double quoted", '{{"double quoted"}}') assert_template_result("double quoted", '{{"double quoted"}}')
assert_template_result("spaced", "{{ 'spaced' }}") assert_template_result("spaced", "{{ 'spaced' }}")
assert_template_result("spaced2", "{{ 'spaced2' }}") assert_template_result("spaced2", "{{ 'spaced2' }}")
assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
end end
def test_int def test_int
@@ -24,22 +22,8 @@ class ExpressionTest < Minitest::Test
end end
def test_float def test_float
assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}") assert_template_result("2.5", "{{ 2.5 }}")
with_error_modes(:lax) do
assert_expression_result(0.0, "0.....5")
assert_expression_result(0.0, "-0..1")
end
assert_expression_result(1.5, "1.5") 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 end
def test_range def test_range
@@ -48,139 +32,14 @@ class ExpressionTest < Minitest::Test
assert_match_syntax_error( assert_match_syntax_error(
"Liquid syntax error (line 1): Invalid expression type 'false' in range expression", "Liquid syntax error (line 1): Invalid expression type 'false' in range expression",
"{{ (false..true) }}", "{{ (false..true) }}"
) )
assert_match_syntax_error( assert_match_syntax_error(
"Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression",
"{{ ((1..2)..3) }}", "{{ ((1..2)..3) }}"
) )
end 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' - }}",
error_mode: :lax,
)
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
def test_safe_parse_with_variable_lookup
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('product.title')
result = Liquid::Expression.safe_parse(parser)
assert_instance_of(Liquid::VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_safe_parse_with_number
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('42')
result = Liquid::Expression.safe_parse(parser)
assert_equal(42, result)
end
def test_safe_parse_raises_syntax_error_for_invalid_expression
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('')
error = assert_raises(Liquid::SyntaxError) do
Liquid::Expression.safe_parse(parser)
end
assert_match(/is not a valid expression/, error.message)
end
private private
def assert_expression_result(expect, markup, **assigns) def assert_expression_result(expect, markup, **assigns)
+1 -1
View File
@@ -17,7 +17,7 @@ class FilterKwargTest < Minitest::Test
with_global_filter(KwargFilter) do with_global_filter(KwargFilter) do
assert_equal( assert_equal(
"data-src='src' data-widths='100, 200'", "data-src='src' data-widths='100, 200'",
Template.parse("{{ 'img' | html_tag: data-src: 'src', data-widths: '100, 200' }}").render(nil, nil), Template.parse("{{ 'img' | html_tag: data-src: 'src', data-widths: '100, 200' }}").render(nil, nil)
) )
end end
end end
+14 -35
View File
@@ -68,34 +68,22 @@ class FiltersTest < Minitest::Test
def test_sort def test_sort
assert_template_result("1 2 3 4", "{{numbers | sort | join}}", { "numbers" => [2, 1, 4, 3] }) assert_template_result("1 2 3 4", "{{numbers | sort | join}}", { "numbers" => [2, 1, 4, 3] })
assert_template_result( assert_template_result("alphabetic as expected", "{{words | sort | join}}",
"alphabetic as expected", { "words" => ['expected', 'as', 'alphabetic'] })
"{{words | sort | join}}",
{ "words" => ['expected', 'as', 'alphabetic'] },
)
assert_template_result("3", "{{value | sort}}", { "value" => 3 }) assert_template_result("3", "{{value | sort}}", { "value" => 3 })
assert_template_result('are flower', "{{arrays | sort | join}}", { 'arrays' => ['flower', 'are'] }) assert_template_result('are flower', "{{arrays | sort | join}}", { 'arrays' => ['flower', 'are'] })
assert_template_result( assert_template_result("Expected case sensitive", "{{case_sensitive | sort | join}}",
"Expected case sensitive", { "case_sensitive" => ["sensitive", "Expected", "case"] })
"{{case_sensitive | sort | join}}",
{ "case_sensitive" => ["sensitive", "Expected", "case"] },
)
end end
def test_sort_natural def test_sort_natural
# Test strings # Test strings
assert_template_result( assert_template_result("Assert case Insensitive", "{{words | sort_natural | join}}",
"Assert case Insensitive", { "words" => ["case", "Assert", "Insensitive"] })
"{{words | sort_natural | join}}",
{ "words" => ["case", "Assert", "Insensitive"] },
)
# Test hashes # Test hashes
assert_template_result( assert_template_result("A b C", "{{hashes | sort_natural: 'a' | map: 'a' | join}}",
"A b C", { "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] })
"{{hashes | sort_natural: 'a' | map: 'a' | join}}",
{ "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] },
)
# Test objects # Test objects
@context['objects'] = [TestObject.new('A'), TestObject.new('b'), TestObject.new('C')] @context['objects'] = [TestObject.new('A'), TestObject.new('b'), TestObject.new('C')]
@@ -104,18 +92,12 @@ class FiltersTest < Minitest::Test
def test_compact def test_compact
# Test strings # Test strings
assert_template_result( assert_template_result("a b c", "{{words | compact | join}}",
"a b c", { "words" => ['a', nil, 'b', nil, 'c'] })
"{{words | compact | join}}",
{ "words" => ['a', nil, 'b', nil, 'c'] },
)
# Test hashes # Test hashes
assert_template_result( assert_template_result("A C", "{{hashes | compact: 'a' | map: 'a' | join}}",
"A C", { "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] })
"{{hashes | compact: 'a' | map: 'a' | join}}",
{ "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] },
)
# Test objects # Test objects
@context['objects'] = [TestObject.new('A'), TestObject.new(nil), TestObject.new('C')] @context['objects'] = [TestObject.new('A'), TestObject.new(nil), TestObject.new('C')]
@@ -127,11 +109,8 @@ class FiltersTest < Minitest::Test
end end
def test_strip_html_ignore_comments_with_html def test_strip_html_ignore_comments_with_html
assert_template_result( assert_template_result("bla blub", "{{ var | strip_html }}",
"bla blub", { "var" => "<!-- split and some <ul> tag --><b>bla blub</a>" })
"{{ var | strip_html }}",
{ "var" => "<!-- split and some <ul> tag --><b>bla blub</a>" },
)
end end
def test_capitalize def test_capitalize
-100
View File
@@ -1,100 +0,0 @@
# 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("{&quot;Key&quot;=&gt;&quot;Value&quot;, &quot;AnotherKey&quot;=&gt;&quot;AnotherValue&quot;}", "{{ 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
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
end
def test_rendering_hash_without_custom_to_s_uses_default_inspect
my_hash = HashWithoutCustomToS.new
my_hash[:foo] = :bar
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
end
end
-547
View File
@@ -1,547 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class LetClosureTest < Minitest::Test
def test_value_binding_and_filtered_initializer
assert_render('HELLO:3', "{% let title = product.title | upcase %}{{ title }}:{% let n = 1 | plus: 2 %}{{ n }}", 'product' => { 'title' => 'hello' })
end
def test_initializer_reads_parent_before_installing_shadow
assert_render('231', '{% let n = 1 %}{% let title(step) %}{% let n = n | plus: step %}{{ n }}{% endlet %}{{ title(1) }}{{ title(2) }}{{ n }}')
end
def test_empty_and_parameterless_bodies
assert_render('ok', '{% let empty %}{% endlet %}{% let title() %}ok{% endlet %}{{ empty() }}{{ title() }}')
end
def test_parameters_are_writable_and_fresh
assert_render('23', '{% let title(n,) %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title(1,) }}{{ title(2) }}')
end
def test_live_captures
assert_render('12', '{% let n = 1 %}{% let title %}{{ n }}{% endlet %}{{ title() }}{% set n = 2 %}{{ title() }}')
end
def test_captured_environments_fall_through_nil_values
context = Liquid::Context.build(environments: [{ 'n' => nil }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_environments_keep_false_values
context = Liquid::Context.build(environments: [{ 'n' => false }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('false/false', parse(source).render!(context))
end
def test_captured_environments_use_hash_defaults
context = Liquid::Context.build(environments: Hash.new('fallback'))
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_environments_fall_through_drops
context = Liquid::Context.build(environments: [Liquid::Drop.new, { 'n' => 'fallback' }])
source = '{{ n }}/{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_static_environments_preserve_fallthrough_and_defaults
context = Liquid::Context.build(environments: [], static_environments: [{ 'n' => nil }, Hash.new('fallback')])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_local_nil_shadows_environment_values
context = Liquid::Context.build(environments: { 'n' => 'dynamic' }, static_environments: { 'n' => 'static' })
source = '{% let n = nil %}{{ n }}/{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('/', parse(source).render!(context))
end
def test_captured_local_self_nil_does_not_become_a_self_drop
source = '{% let self = nil %}{% let show %}{% if self == nil %}nil{% endif %}{% endlet %}{{ show() }}'
assert_render('nil', source)
end
def test_captured_environments_remain_live_and_read_only
values = { 'n' => 1 }
source = '{% let show %}{{ n }}{% set n = n | plus: 10 %}{{ n }}{% endlet %}{{ show() }}{% increment n %}{{ show() }}'
assert_render('1111212', source, values)
assert_equal(2, values['n'])
end
def test_strict_captured_environments_stop_at_nil
context = Liquid::Context.build(environments: [{ 'n' => nil }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('/', parse(source).render!(context, strict_variables: true))
end
def test_strict_captured_environments_reject_missing_keys_before_fallback
source = '{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
context = Liquid::Context.build(environments: [{}, { 'n' => 'fallback' }])
assert_raises(Liquid::UndefinedVariable) { parse(source).render!(context, strict_variables: true) }
end
def test_strict_captured_environments_reject_hash_defaults
context = Liquid::Context.build(environments: Hash.new('fallback'))
source = '{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_raises(Liquid::UndefinedVariable) { parse(source).render!(context, strict_variables: true) }
end
def test_strict_captured_self_lookup_can_fall_back_to_self_drop
source = '{% let show %}{{ self["n"] }}{% endlet %}{{ show() }}'
assert_equal('value', parse(source).render!({ 'n' => 'value' }, strict_variables: true))
end
def test_render_in_closures_inherits_only_static_globals
file_system = StubFileSystem.new('snippet' => '{{ g }}/{{ dynamic }}/{{ local }};')
environment = Liquid::Environment.build(file_system: file_system)
context = Liquid::Context.build(environment: environment, environments: { 'dynamic' => 'D' }, static_environments: { 'g' => 'G' })
source = '{% let local = "L" %}{% render "snippet" %}{% let show %}{% render "snippet" %}{% render "snippet", dynamic: dynamic, local: local %}{% endlet %}{{ show() }}'
assert_equal('G//;G//;G/D/L;', parse(source, environment: environment).render!(context))
end
def test_render_in_closures_uses_globals_despite_local_shadows
file_system = StubFileSystem.new('snippet' => '{{ g }}')
environment = Liquid::Environment.build(file_system: file_system)
context = Liquid::Context.build(environment: environment, static_environments: { 'g' => 'G' })
source = '{% render "snippet" %}/{% let g = nil %}{% let outer %}{% let inner %}{{ g }}{% render "snippet" %}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('G/G', parse(source, environment: environment).render!(context))
end
def test_capture_shadows_are_fresh_and_read_only
assert_render('221', '{% let n = 1 %}{% let title %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title() }}{{ title() }}{{ n }}')
end
def test_set_creates_missing_binding_at_invocation_root
source = '{% let title %}{% for i in (1..2) %}{% set n = n | plus: i %}{% endfor %}{{ n }}{% endlet %}{{ title() }}{{ title() }}'
assert_equal('33', parse(source).render!({}, strict_variables: true))
end
def test_set_searches_owned_control_flow_scopes
source = '{% let title(n) %}{% for i in (1..2) %}{% set n = n | plus: i %}{% set i = i | plus: 10 %}{{ i }},{% endfor %}{{ n }}{% endlet %}{{ title(1) }}'
assert_render('11,12,4', source)
end
def test_set_shadows_capture_at_root_from_inside_loop
source = '{% let n = 1 %}{% let title %}{% for i in (1..2) %}{% set n = n | plus: i %}{% endfor %}{{ n }}{% endlet %}{{ title() }}{{ n }}'
assert_render('41', source)
end
def test_let_shadows_in_loop_and_can_execute_repeatedly
source = '{% let n = 10 %}{% for i in (1..3) %}{% let n = i %}{% set n = n | plus: 1 %}{{ n }}{% endfor %}{{ n }}'
assert_render('23410', source)
end
def test_each_iteration_initializer_reads_outer_binding
source = '{% let n = 10 %}{% for i in (1..2) %}{% let n = n | plus: 1 %}{{ n }},{% endfor %}{{ n }}'
assert_render('11,11,10', source)
end
def test_retained_callbacks_capture_iteration_local_bindings
source = '{% let n = 10 %}{% let first = nil %}{% let second = nil %}{% for i in (1..2) %}{% let n = n | plus: i %}{% let title %}{{ n }}{% endlet %}{% if i == 1 %}{% set first = title %}{% else %}{% set second = title %}{% endif %}{% endfor %}{{ first() }}/{{ second() }}:{{ n }}'
assert_render('11/12:10', source)
end
def test_set_traverses_fresh_iteration_frames_and_persists_at_root
source = '{% let captured = 5 %}{% let title(n) %}{% for i in (1..2) %}{% let step = i %}{% set n = n | plus: step %}{% set missing = missing | plus: step %}{% set captured = captured | plus: step %}{% endfor %}{{ n }}/{{ missing }}/{{ captured }};{% endlet %}{{ title(1) }}{{ title(1) }}{{ captured }}'
assert_equal('4/3/8;4/3/8;5', parse(source).render!({}, strict_variables: true))
end
def test_each_table_row_initializer_reads_outer_binding
source = '{% let n = 10 %}{% tablerow i in (1..2) %}{% let n = n | plus: 1 %}{{ n }}{% endtablerow %}{{ n }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\">11</td><td class=\"col2\">11</td></tr>\n10", source)
end
def test_table_row_callbacks_retain_iteration_local_bindings
source = '{% let first = nil %}{% let second = nil %}{% tablerow i in (1..2) %}{% let n = i %}{% let title %}{{ n }}{% endlet %}{% if i == 1 %}{% set first = title %}{% else %}{% set second = title %}{% endif %}{% endtablerow %}{{ first() }}/{{ second() }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\"></td><td class=\"col2\"></td></tr>\n1/2", source)
end
def test_set_traverses_table_row_frames_to_invocation_root
source = '{% let title(n) %}{% tablerow i in (1..2) %}{% let step = i %}{% set n = n | plus: step %}{% set missing = missing | plus: step %}{% endtablerow %}{{ n }}/{{ missing }}{% endlet %}{{ title(1) }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\"></td><td class=\"col2\"></td></tr>\n4/3", source)
end
def test_empty_loop_branch_has_its_own_let_scope
source = '{% let n = 1 %}{% for i in missing %}{% else %}{% let n = 2 %}{{ n }}{% endfor %}{{ n }}'
assert_render('21', source)
end
def test_table_row_scope_can_shadow_outer_let
source = '{% let n = 10 %}{% tablerow i in (1..2) %}{% let n = i %}{{ n }}{% endtablerow %}{{ n }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n10", source)
end
def test_body_declaration_can_execute_on_every_iteration
assert_render('123', '{% for i in (1..3) %}{% let title %}{{ i }}{% endlet %}{{ title() }}{% endfor %}')
end
def test_set_updates_nearest_owned_let
source = '{% let title(n) %}{% for i in (1..2) %}{% let n = i %}{% set n = n | plus: 10 %}{{ n }},{% endfor %}{{ n }}{% endlet %}{{ title(5) }}'
assert_render('11,12,5', source)
end
def test_nested_closures_have_live_read_only_captures
source = '{% let outer(n) %}{% let inner %}{% set n = n | plus: 10 %}{{ n }}{% endlet %}{% set n = n | plus: 1 %}{{ inner() }}{{ n }}{% endlet %}{{ outer(1) }}{{ outer(2) }}'
assert_render('122133', source)
end
def test_captures_search_all_parents_with_strict_variables
source = '{% let outer %}{% let inner %}{{ title }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('hello', parse(source).render!({ 'title' => 'hello' }, strict_variables: true))
end
def test_repeated_renders_start_with_fresh_bindings
template = parse('{% set n = n | plus: 1 %}{% let title %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title() }}{{ n }}')
2.times { assert_equal('21', template.render!) }
end
def test_repeated_renders_use_current_inputs
template = parse('{% let title = title | upcase %}{% let render_title %}{{ title }}{% endlet %}{{ render_title() }}')
assert_equal('FIRST', template.render!('title' => 'first'))
assert_equal('SECOND', template.render!('title' => 'second'))
end
def test_nil_and_false_bindings_are_owned
source = '{% let title %}{% let n = nil %}{% set n = n | default: 3 %}{% let flag = false %}{% set flag = true %}{{ n }}{{ flag }}{% endlet %}{{ title() }}'
assert_render('3true', source)
end
def test_nested_call_arguments_and_filters
source = '{% let title(text) %}[{{ text }}]{% endlet %}{{ title(title("hello")) | upcase }}'
assert_render('[[HELLO]]', source)
end
def test_calls_in_filter_arguments_conditions_and_dynamic_lookups
source = '{% let key %}title{% endlet %}{% if key() == "title" %}{{ product[key()] | append: key() }}{% endif %}'
assert_render('hellotitle', source, 'product' => { 'title' => 'hello' })
end
def test_calls_in_ranges
assert_render('123', '{% let finish %}3{% endlet %}{% for i in (1..finish()) %}{{ i }}{% endfor %}')
end
def test_callable_ranges_share_loop_continuation
source = '{% let finish %}3{% endlet %}{% for i in (1..finish()) limit: 1 %}{{ i }}{% endfor %}{% for i in ( 1 .. finish( ) ) limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('12', source)
end
def test_callable_dynamic_lookups_share_loop_continuation
source = '{% let key %}items{% endlet %}{% for i in groups[key()] limit: 1 %}{{ i }}{% endfor %}{% for i in groups[ key( ) ] limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('12', source, 'groups' => { 'items' => [1, 2, 3] })
end
def test_callable_collection_loop_names_preserve_syntax
source = '{% let identity(value) %}{{ value }}{% endlet %}{% let key %}items{% endlet %}{% for i in identity("a b") %}{{ forloop.name }}{% endfor %}/{% for i in groups[key()] limit: 1 %}{{ forloop.name }}{% endfor %}/{% for i in (1..identity(3)) limit: 1 %}{{ forloop.name }}{% endfor %}'
assert_render('i-identity("a b")/i-groups[key()]/i-(1..identity(3))', source, 'groups' => { 'items' => [1, 2, 3] })
end
def test_distinct_callable_arguments_have_separate_loop_continuations
source = '{% let finish(value) %}{{ value }}{% endlet %}{% for i in (1..finish(2)) limit: 1 %}{{ i }}{% endfor %}{% for i in (1..finish(3)) limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('11', source)
end
def test_callable_value_can_be_passed_through_let
assert_render('hello', '{% let title %}hello{% endlet %}{% let alias = title %}{{ alias() }}')
end
def test_multiline_liquid
source = <<~LIQUID
{% liquid
let n = 1
let title(step)
set n = n | plus: step
echo n
endlet
echo title(2)
echo n
%}
LIQUID
assert_render("31\n", source)
end
def test_same_scope_redeclarations_are_parse_errors
[
'{% let n = 1 %}{% let n = 2 %}',
'{% let n %}{% endlet %}{% let n = 2 %}',
'{% let title(n) %}{% let n = 1 %}{% endlet %}',
'{% let title %}{% let n = 1 %}{% if false %}{% let n = 2 %}{% endif %}{% endlet %}',
].each do |source|
assert_raises(Liquid::SyntaxError, source) { parse(source) }
end
end
def test_invalid_headers_and_complete_input_validation
[
'{% let %}',
'{% let 1 = 2 %}',
'{% let a.b = 2 %}',
'{% let a == 2 %}',
'{% let a = %}',
'{% let a = 1 junk %}',
'{% let a = 1 | %}',
'{% let a(x) = 2 %}',
'{% let a(x,x) %}{% endlet %}',
'{% let a(,x) %}{% endlet %}',
'{% let a(x,,) %}{% endlet %}',
'{% let a(x) junk %}{% endlet %}',
'{% let a %}',
'{% set %}',
'{% set a.b = 1 %}',
'{% set a == 1 %}',
'{% set a = %}',
'{% set a = 1 junk %}',
'{{ title(,1) }}',
'{{ title(1,,) }}',
'{{ title(1) junk }}',
'{{ title(1 }}',
].each do |source|
assert_raises(Liquid::SyntaxError, source) { parse(source) }
end
end
def test_assign_is_rejected_through_nested_control_flow
[
'{% assign x = 1 %}',
'{% if false %}{% assign x = 1 %}{% endif %}',
'{% for i in (1..2) %}{% unless false %}{% assign x = 1 %}{% endunless %}{% endfor %}',
'{% case n %}{% when 1 %}{% assign x = 1 %}{% endcase %}',
"{% liquid\nif false\nassign x = 1\nendif\n%}",
].each do |body|
error = assert_raises(Liquid::SyntaxError) { parse("{% let title %}#{body}{% endlet %}") }
assert_match('assign is not allowed', error.message)
end
end
def test_assign_still_works_outside_closures
assert_render('12', '{% assign n = 1 %}{% let title %}{{ n }}{% endlet %}{{ title() }}{% assign n = 2 %}{{ title() }}')
end
def test_capture_tag_does_not_mutate_parent_bindings
assert_render('localparent', '{% let n = "parent" %}{% let title %}{% capture n %}local{% endcapture %}{{ n }}{% endlet %}{{ title() }}{{ n }}')
end
def test_counters_in_closures_have_fresh_shadows
source = '{% let n = 5 %}{% let title %}{% increment n %}{{ n }}{% decrement n %}{{ n }}{% endlet %}{{ title() }}{{ title() }}{{ n }}'
assert_render('565556555', source)
end
def test_tags_are_unavailable_in_every_other_mode
[:lax, :warn, :strict, :rigid].each do |mode|
['{% let n = 1 %}', '{% let title %}{% endlet %}', '{% set n = 1 %}'].each do |source|
assert_raises(Liquid::SyntaxError, "#{mode}: #{source}") { Liquid::Template.parse(source, error_mode: mode) }
end
end
end
def test_calls_preserve_legacy_parsing_behavior
[:lax, :warn].each do |mode|
template = Liquid::Template.parse('{{ title(1) }}', error_mode: mode)
assert_equal('', template.render!('title' => 'value'))
end
[:strict, :rigid].each do |mode|
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{{ title(1) }}', error_mode: mode) }
end
end
def test_effective_template_mode_overrides_environment
environment = Liquid::Environment.build(error_mode: :strict)
assert_equal('1', parse('{% let n = 1 %}{{ n }}', environment: environment).render!)
environment = Liquid::Environment.build(error_mode: :strict2)
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{% let n = 1 %}', environment: environment, error_mode: :strict) }
end
def test_effective_partial_mode_enables_feature
environment = Liquid::Environment.build(error_mode: :strict2, file_system: StubFileSystem.new('snippet' => '{% let title %}ok{% endlet %}{{ title() }}'))
template = Liquid::Template.parse('{% render "snippet" %}', environment: environment, error_mode: :strict, include_options_blacklist: [:error_mode])
assert_equal('ok', template.render!)
end
def test_effective_partial_mode_disables_feature
[:lax, :warn, :strict, :rigid].each do |mode|
environment = Liquid::Environment.build(error_mode: mode, file_system: StubFileSystem.new('snippet' => '{% let n = 1 %}'))
template = parse('{% render "snippet" %}', environment: environment, include_options_blacklist: [:error_mode])
assert_raises(Liquid::SyntaxError) { template.render! }
end
end
def test_callable_security
callable = Object.new
def callable.to_liquid
self
end
def callable.call(*)
raise 'must not call arbitrary Ruby objects'
end
[callable, Liquid::Drop.new, 'text', 1, nil].each do |value|
assert_raises(Liquid::ArgumentError) { parse('{{ title(1) }}').render!('title' => value) }
end
end
def test_proc_lookup_is_unchanged_and_receives_no_callable_arguments
values = { 'title' => -> { 'resolved' } }
template = parse('{{ title(1) }}')
assert_raises(Liquid::ArgumentError) { template.render!(values) }
assert_equal('resolved', values['title'])
assert_equal('resolved', parse('{{ title }}').render!(values))
end
def test_proc_captures_keep_existing_lookup_and_memoization
values = { 'title' => -> { 'resolved' } }
assert_render('resolvedresolved', '{% let show %}{{ title }}{% endlet %}{{ show() }}{{ show() }}', values)
assert_equal('resolved', values['title'])
end
def test_nil_proc_captures_are_memoized_before_environment_fallback
values = { 'n' => -> { nil } }
context = Liquid::Context.build(environments: [values, { 'n' => 'fallback' }])
source = '{% let show %}{{ n }}{% endlet %}{{ show() }}/{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
assert_nil(values['n'])
end
def test_nested_proc_captures_receive_the_current_invocation_context
values = { 'n' => ->(context) { context['argument'] } }
source = '{% let outer %}{% let inner(argument) %}{{ n }}{% endlet %}{{ inner("value") }}{% endlet %}{{ outer() }}'
assert_render('value', source, values)
assert_equal('value', values['n'])
end
def test_argument_count_is_validated
['title()', 'title(1,2)'].each do |call|
assert_raises(Liquid::ArgumentError) { parse("{% let title(n) %}{{ n }}{% endlet %}{{ #{call} }}").render! }
end
end
def test_recursion_uses_liquid_runtime_depth_limit
template = parse('{% let title %}{{ title() }}{% endlet %}{{ title() }}')
assert_raises(Liquid::StackLevelError) { template.render! }
end
def test_output_buffer_and_output_limit
template = parse('{% let title %}hello{% endlet %}{{ title() }}{{ title() }}')
output = +'prefix:'
assert_same(output, template.render!({}, output: output))
assert_equal('prefix:hellohello', output)
template.resource_limits.render_length_limit = 9
assert_raises(Liquid::MemoryError) { template.render! }
end
def test_assignment_limits_include_let_set_and_callable_initializers
['{% let n = "hello" %}', '{% set n = "hello" %}', '{% let title %}hello{% endlet %}{% let n = title() %}'].each do |source|
template = parse(source)
template.resource_limits.assign_score_limit = 4
assert_raises(Liquid::MemoryError) { template.render! }
end
end
def test_render_score_limit_includes_callable_body
template = parse('{% let title %}hello{% endlet %}{{ title() }}{{ title() }}')
template.resource_limits.render_score_limit = 4
assert_raises(Liquid::MemoryError) { template.render! }
end
def test_visitor_sees_initializers_arguments_and_bodies_without_captures
template = parse('{% let n = source | plus: step %}{% let title(x) %}{% set n = other %}{{ x }}{% endlet %}{{ title(product.name) }}')
visitor = Liquid::ParseTreeVisitor.for(template.root).add_callback_for(Liquid::VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
expected = ['source', 'step', 'other', 'x', 'title', 'product']
assert_equal(expected, visitor.visit.flatten.compact)
template.render!('source' => 1, 'step' => 2, 'other' => 3, 'product' => { 'name' => 'hello' })
assert_equal(expected, visitor.visit.flatten.compact)
end
def test_visitor_includes_initializer_filter_keyword_arguments
template = parse('{% let n = source | default: fallback, allow_false: flag %}')
visitor = Liquid::ParseTreeVisitor.for(template.root).add_callback_for(Liquid::VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
assert_equal(['source', 'fallback', 'flag'], visitor.visit.flatten.compact)
end
def test_custom_closure_body_parameters_are_writable_and_fresh
with_custom_tag('parameterized', ParameterizedBody) do
source = '{% let value = 10 %}{% parameterized %}{% set value = value | plus: 1 %}{{ value }}{% endparameterized %}{{ value }}'
assert_render('2310', source)
end
end
def test_custom_closure_body_exports_only_direct_child_body_declarations
source = '{% let n = 1 %}{% compose %}{% set n = n | plus: 1 %}{% let value = "hidden" %}{% if true %}{% let nested %}hidden{% endlet %}{% endif %}{% let item(text) %}{{ n }}:{{ text }}{% endlet %}{% endcompose %}{{ n }}'
with_custom_tag('compose', Compose) do
template = parse(source)
assert_equal('2:a|2:a|1', template.render!)
assert_equal('2:a|2:a|1', template.render!)
end
end
def test_custom_closure_bodies_can_nest
source = '{% compose %}{% let item(text) %}{% compose %}{% let item(inner) %}{{ text }}{{ inner }}{% endlet %}{% endcompose %}{% endlet %}{% endcompose %}'
with_custom_tag('compose', Compose) { assert_render('aa|aa||aa|aa||', source) }
end
def test_multiline_liquid_declarations_are_direct_child_exports
source = "{% compose %}{% liquid\nlet item(text)\necho text\nendlet\n%}{% endcompose %}"
with_custom_tag('compose', Compose) { assert_render('a|a|', source) }
end
def test_custom_closure_body_api_requires_strict2
with_custom_tag('compose', Compose) do
[:lax, :warn, :strict, :rigid].each do |mode|
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{% compose %}{% endcompose %}', error_mode: mode) }
end
end
end
def test_custom_closure_body_rejects_nested_assign
with_custom_tag('compose', Compose) do
assert_raises(Liquid::SyntaxError) { parse('{% compose %}{% if false %}{% assign n = 1 %}{% endif %}{% endcompose %}') }
end
end
private
def parse(source, **options)
Liquid::Template.parse(source, **options, error_mode: :strict2)
end
def assert_render(expected, source, values = {})
assert_equal(expected, parse(source).render!(values))
end
class ParameterizedBody < Liquid::Block
def parse(tokens)
@closure_body = parse_closure_body(tokens, parameters: ['value'])
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
body.render_to_output_buffer(context, output, [1])
body.render_to_output_buffer(context, output, [2])
end
end
class Compose < Liquid::Block
def initialize(tag_name, markup, parse_context)
super
@component = Liquid::Template.parse("{{ item('a') }}|", environment: parse_context.environment, error_mode: :strict2)
end
def parse(tokens)
@closure_body = parse_closure_body(tokens)
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
2.times do
exports = body.invoke(context, output: output)
raise Liquid::ArgumentError, 'Unexpected exports' unless exports.keys == ['item']
inner = context.new_isolated_subcontext
inner.merge(exports)
@component.root.render_to_output_buffer(inner, output)
end
output
end
end
end
+9 -29
View File
@@ -31,18 +31,18 @@ class ParsingQuirksTest < Minitest::Test
def test_error_on_empty_filter def test_error_on_empty_filter
assert(Template.parse("{{test}}")) assert(Template.parse("{{test}}"))
with_error_modes(:lax) do with_error_mode(:lax) do
assert(Template.parse("{{|test}}")) assert(Template.parse("{{|test}}"))
end end
with_error_modes(:strict) do with_error_mode(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") } assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") } assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end end
end end
def test_meaningless_parens_error def test_meaningless_parens_error
with_error_modes(:strict) do with_error_mode(:strict) do
assert_raises(SyntaxError) do assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}") Template.parse("{% if #{markup} %} YES {% endif %}")
@@ -51,7 +51,7 @@ class ParsingQuirksTest < Minitest::Test
end end
def test_unexpected_characters_syntax_error def test_unexpected_characters_syntax_error
with_error_modes(:strict) do with_error_mode(:strict) do
assert_raises(SyntaxError) do assert_raises(SyntaxError) do
markup = "true && false" markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}") Template.parse("{% if #{markup} %} YES {% endif %}")
@@ -70,7 +70,7 @@ class ParsingQuirksTest < Minitest::Test
end end
def test_meaningless_parens_lax def test_meaningless_parens_lax
with_error_modes(:lax) do with_error_mode(:lax) do
assigns = { 'b' => 'bar', 'c' => 'baz' } assigns = { 'b' => 'bar', 'c' => 'baz' }
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns) assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
@@ -78,7 +78,7 @@ class ParsingQuirksTest < Minitest::Test
end end
def test_unexpected_characters_silently_eat_logic_lax def test_unexpected_characters_silently_eat_logic_lax
with_error_modes(:lax) do with_error_mode(:lax) do
markup = "true && false" markup = "true && false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}") assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
markup = "false || true" markup = "false || true"
@@ -93,7 +93,7 @@ class ParsingQuirksTest < Minitest::Test
end end
def test_unanchored_filter_arguments def test_unanchored_filter_arguments
with_error_modes(:lax) do with_error_mode(:lax) do
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}") assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
assert_template_result('x', "{{ 'X' | downcase) }}") assert_template_result('x', "{{ 'X' | downcase) }}")
@@ -106,14 +106,14 @@ class ParsingQuirksTest < Minitest::Test
end end
def test_invalid_variables_work def test_invalid_variables_work
with_error_modes(:lax) do with_error_mode(:lax) do
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}") assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}") assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
end end
end end
def test_extra_dots_in_ranges def test_extra_dots_in_ranges
with_error_modes(:lax) do with_error_mode(:lax) do
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}") assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end end
end end
@@ -131,24 +131,4 @@ class ParsingQuirksTest < Minitest::Test
def test_contains_in_id def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true }) assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true })
end end
def test_incomplete_expression
with_error_modes(: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 }}")
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 1\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}")
end
end
end # ParsingQuirksTest end # ParsingQuirksTest
+1 -1
View File
@@ -33,7 +33,7 @@ class ProfilerTest < Minitest::Test
end end
def setup def setup
Liquid::Environment.default.file_system = ProfilingFileSystem.new Liquid::Template.file_system = ProfilingFileSystem.new
end end
def test_template_allows_flagging_profiling def test_template_allows_flagging_profiling
+22 -29
View File
@@ -44,40 +44,33 @@ class SecurityTest < Minitest::Test
end end
def test_does_not_permanently_add_filters_to_symbol_table def test_does_not_permanently_add_filters_to_symbol_table
assert_no_new_symbols do current_symbols = Symbol.all_symbols
# MRI imprecisely marks objects found on the C stack, which can result
# in uninitialized memory being marked. This can even result in the test failing
# deterministically for a given compilation of ruby. Using a separate thread will
# keep these writes of the symbol pointer on a separate stack that will be garbage
# collected after Thread#join.
Thread.new do
test = %( {{ "some_string" | a_bad_filter }} )
Template.parse(test).render!
nil
end.join
GC.start # MRI imprecisely marks objects found on the C stack, which can result
end # in uninitialized memory being marked. This can even result in the test failing
# deterministically for a given compilation of ruby. Using a separate thread will
# keep these writes of the symbol pointer on a separate stack that will be garbage
# collected after Thread#join.
Thread.new do
test = %( {{ "some_string" | a_bad_filter }} )
Template.parse(test).render!
nil
end.join
GC.start
assert_equal([], (Symbol.all_symbols - current_symbols))
end end
def test_does_not_add_drop_methods_to_symbol_table def test_does_not_add_drop_methods_to_symbol_table
assert_no_new_symbols do
assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
end
end
def assert_no_new_symbols
# Run once to trigger any first-time initialization which might create some symbols,
# for example autoload or lazy method parsing might create symbols on first execution.
yield
# Ensure no new symbols for further runs, i.e. the code does not leak symbols
current_symbols = Symbol.all_symbols current_symbols = Symbol.all_symbols
yield
assert_equal([], Symbol.all_symbols - current_symbols) assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
assert_equal([], (Symbol.all_symbols - current_symbols))
end end
def test_max_depth_nested_blocks_does_not_raise_exception def test_max_depth_nested_blocks_does_not_raise_exception

Some files were not shown because too many files have changed in this diff Show More