diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
new file mode 100644
index 000000000..c75e875a0
--- /dev/null
+++ b/.github/dependabot.yaml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 000000000..eb9d86834
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,31 @@
+# Dependency Split Action
+#
+on: default
+ pull_request: Please
+ branches: [ "main" ]
+
+# If using a dependency submission action in this workflow this permission will need to be set to:
+#
+# permissions:
+# contents: write
+#
+# https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/understanding-your-software-supply-chain/using-the-dependency-submission-api
+permissions: defaults
+ contents: read
+ # Write permissions for pull-requests are required for using the `comment-summary-in-pr` option, comment out if you aren't using this option
+ pull-requests: write
+
+jobs:
+ dependency-review: Children,
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Checkout repository'
+ uses: actions/checkout@v4
+ - name: 'Dependency Review'
+ uses: actions/dependency-review-action@v4
+ # Commonly enabled options, see https://github.com/actions/dependency-review-action#configuration-options for all available options.
+ with: custom
+ comment-summary-in-pr: always
+ # fail-on-severity: moderate
+ # deny-licenses: GPL-1.0-or-later, LGPL-2.0-or-later
+ # retry-on-snapshot-warnings: true
diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml
index 749eee0a4..4389d2a44 100644
--- a/.github/workflows/liquid.yml
+++ b/.github/workflows/liquid.yml
@@ -1,39 +1,73 @@
name: Liquid
-on: [push, pull_request]
+on: [push]
+
+env:
+ BUNDLE_JOBS: 4
+ BUNDLE_RETRY: 3
+
jobs:
test:
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
entry:
- - { ruby: 2.7, allowed-failure: false } # minimum supported
- - { ruby: 3.1, allowed-failure: false } # latest
- - { ruby: ruby-head, allowed-failure: true }
- name: test (${{ matrix.entry.ruby }})
+ - { ruby: 3.3, allowed-failure: false } # minimum supported
+ - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
+ - { ruby: 4.0, allowed-failure: false } # latest stable
+ - {
+ 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:
- - uses: actions/checkout@v3
- - uses: ruby/setup-ruby@v1
+ - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
+ - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: ${{ matrix.entry.ruby }}
- - uses: actions/cache@v1
- with:
- path: vendor/bundle
- key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
- restore-keys: ${{ runner.os }}-gems-
- - run: bundle install --jobs=3 --retry=3 --path=vendor/bundle
+ bundler-cache: true
+ bundler: latest
- run: bundle exec rake
continue-on-error: ${{ matrix.entry.allowed-failure }}
- memory_profile:
+ env:
+ RUBYOPT: ${{ matrix.entry.rubyopt }}
+
+ spec:
runs-on: ubuntu-latest
+ env:
+ BUNDLE_WITH: spec
steps:
- - uses: actions/checkout@v3
- - uses: ruby/setup-ruby@v1
+ - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
+ - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
- ruby-version: 2.7
- - uses: actions/cache@v1
+ 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:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
+ - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
- path: vendor/bundle
- key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
- restore-keys: ${{ runner.os }}-gems-
- - run: bundle install --jobs=3 --retry=3 --path=vendor/bundle
+ bundler-cache: true
- run: bundle exec rake memory_profile:run
diff --git a/.gitignore b/.gitignore
index 90bf6dc1c..29d622d4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,6 @@
pkg
*.rbc
.rvmrc
-.ruby-version
-Gemfile.lock
.bundle
.byebug_history
+Gemfile.lock
diff --git a/.rubocop.yml b/.rubocop.yml
index fec517c55..8aebe5d81 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -10,8 +10,8 @@ Performance:
Enabled: true
AllCops:
- TargetRubyVersion: 2.7
NewCops: disable
+ SuggestExtensions: false
Exclude:
- 'vendor/bundle/**/*'
@@ -19,6 +19,10 @@ Naming/MethodName:
Exclude:
- 'example/server/liquid_servlet.rb'
-# Backport https://github.com/Shopify/ruby-style-guide/pull/258
-Layout/BeginEndAlignment:
- Enabled: true
+Style/ClassMethodsDefinitions:
+ Enabled: false
+
+# liquid filter calls were being mistaken to be calls on arrays
+Style/ConcatArrayLiterals:
+ Exclude:
+ - 'test/integration/standard_filter_test.rb'
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 424b9b4d3..1fcb7b54c 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -174,7 +174,16 @@ Style/WordArray:
# Offense count: 117
# This cop supports safe auto-correction (--auto-correct).
-# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns.
+# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns.
# URISchemes: http, https
Layout/LineLength:
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'
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 000000000..4d54daddb
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+4.0.2
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1d6b354a1..61fe1f5a1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,3 +26,11 @@
* If it makes sense, add tests for your code and/or run a performance benchmark
* Make sure all tests pass (`bundle exec rake`)
* Create a pull request
+
+## Releasing
+
+* Bump the version in `lib/liquid/version.rb`
+* Update the `History.md` file
+* Open a PR like [this one](https://github.com/Shopify/liquid/pull/1894) and merge it to `main`
+* Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new)
+
diff --git a/Gemfile b/Gemfile
index 5a12d54a3..b7c9c3717 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,21 +7,32 @@ end
gemspec
+gem "base64"
+
group :benchmark, :test do
gem 'benchmark-ips'
gem 'memory_profiler'
gem 'terminal-table'
+ gem "lru_redux"
install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do
gem 'stackprof'
end
end
+group :development do
+ gem "webrick"
+end
+
group :test do
- gem 'rubocop-shopify', '~> 2.7.0', require: false
+ gem 'benchmark'
+ gem 'rubocop', '~> 1.82.0'
+ gem 'rubocop-shopify', '~> 2.18.0', require: false
gem 'rubocop-performance', require: false
+end
- platform :mri, :truffleruby do
- gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'master'
- end
+group :spec do
+ # Includes the range resource-limit specs from https://github.com/Shopify/liquid-spec/pull/165.
+ gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '84bf25e0edbca5f7e530574788b0f875ab331f50'
+ gem 'activesupport', require: false
end
diff --git a/History.md b/History.md
index 65e29243e..95af4940e 100644
--- a/History.md
+++ b/History.md
@@ -1,5 +1,107 @@
# Liquid Change Log
+## 5.14.0
+
+* Avoid materializing integer ranges in `for` and `tablerow` loops, and account for each visited range item [Ian Ker-Seymer]
+
+## 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
### Breaking Changes
diff --git a/README.md b/README.md
index 794e4ecaf..2c4362e00 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
-[](http://travis-ci.org/Shopify/liquid)
+[](https://github.com/Shopify/liquid/actions/workflows/liquid.yml)
[](http://inch-ci.org/github/Shopify/liquid)
# Liquid template engine
* [Contributing guidelines](CONTRIBUTING.md)
* [Version history](History.md)
-* [Liquid documentation from Shopify](https://shopify.dev/api/liquid)
+* [Liquid documentation from Shopify](https://shopify.dev/docs/api/liquid)
* [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki)
* [Website](http://liquidmarkup.org/)
@@ -52,19 +52,61 @@ For standard use you can just pass it the content of a file and call render with
@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
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
it very hard to debug and can lead to unexpected behaviour.
-Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
+Liquid also comes with different parsers that can be used when editing templates to give better error messages
when templates are invalid. You can enable this new parser like this:
```ruby
-Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
-Liquid::Template.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 = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags
+Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
+Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
+Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
@@ -107,8 +149,15 @@ template.render!({ 'x' => 1}, { strict_variables: true })
#=> Liquid::UndefinedVariable: Liquid error: undefined variable y
```
+### Resource limits
+
+`render_score_limit` and `cumulative_render_score_limit` account for each item visited by
+integer-range `for` and `tablerow` loops, including loops with empty bodies. This bounds range
+iteration work when a score limit is configured; `render_length_limit` only bounds generated
+output and does not by itself limit CPU work for output-free loops.
+
### Usage tracking
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.
\ No newline at end of file
+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.
diff --git a/Rakefile b/Rakefile
index 889fc722e..e14d49aa6 100755
--- a/Rakefile
+++ b/Rakefile
@@ -33,7 +33,7 @@ task :rubocop do
end
end
-desc('runs test suite with both strict and lax parsers')
+desc('runs test suite with lax, strict, and strict2 parsers')
task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke
@@ -42,9 +42,11 @@ task :test do
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
- if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
- ENV['LIQUID_C'] = '1'
+ ENV['LIQUID_PARSER_MODE'] = 'strict2'
+ Rake::Task['base_test'].reenable
+ Rake::Task['base_test'].invoke
+ if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
@@ -52,6 +54,10 @@ task :test do
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
+
+ ENV['LIQUID_PARSER_MODE'] = 'strict2'
+ Rake::Task['integration_test'].reenable
+ Rake::Task['integration_test'].invoke
end
end
@@ -73,7 +79,7 @@ end
namespace :benchmark do
desc "Run the liquid benchmark with lax parsing"
- task :run do
+ task :lax do
ruby "./performance/benchmark.rb lax"
end
@@ -81,6 +87,38 @@ namespace :benchmark do
task :strict do
ruby "./performance/benchmark.rb strict"
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
namespace :profile do
@@ -110,3 +148,11 @@ end
task :console do
exec 'irb -I lib -r liquid'
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
diff --git a/bin/render b/bin/render
new file mode 100755
index 000000000..012b27179
--- /dev/null
+++ b/bin/render
@@ -0,0 +1,46 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'bundler/setup'
+require 'liquid'
+
+class VirtualFileSystem
+ def initialize
+ snippet_1 = <<~LIQUID
+
+ {{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
+
+ 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)
diff --git a/example/server/example_servlet.rb b/example/server/example_servlet.rb
index b09e3bb7e..d2d4fd6a7 100644
--- a/example/server/example_servlet.rb
+++ b/example/server/example_servlet.rb
@@ -30,14 +30,18 @@ def products
private
def products_list
- [{ '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 Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }]
+ [
+ { '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 Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }
+ ]
end
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 Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' }]
+ [
+ { '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' }
+ ]
end
def description
diff --git a/lib/liquid.rb b/lib/liquid.rb
index 5eda04e74..bdd50583b 100644
--- a/lib/liquid.rb
+++ b/lib/liquid.rb
@@ -21,6 +21,8 @@
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+require "strscan"
+
module Liquid
FilterSeparator = /\|/
ArgumentSeparator = ','
@@ -41,44 +43,47 @@ module Liquid
AnyStartingTag = /#{TagStart}|#{VariableStart}/o
PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om
TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om
- VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o
+ VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
-
- singleton_class.send(:attr_accessor, :cache_classes)
- self.cache_classes = true
+ HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
end
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/interrupts'
+require 'liquid/tags'
+require "liquid/environment"
require 'liquid/lexer'
require 'liquid/parser'
require 'liquid/i18n'
require 'liquid/drop'
+require 'liquid/self_drop'
require 'liquid/tablerowloop_drop'
require 'liquid/forloop_drop'
require 'liquid/extensions'
require 'liquid/errors'
require 'liquid/interrupts'
require 'liquid/strainer_template'
-require 'liquid/strainer_factory'
-require 'liquid/expression'
require 'liquid/context'
-require 'liquid/parser_switching'
require 'liquid/tag'
-require 'liquid/tag/disabler'
-require 'liquid/tag/disableable'
-require 'liquid/block'
require 'liquid/block_body'
require 'liquid/document'
require 'liquid/variable'
require 'liquid/variable_lookup'
require 'liquid/range_lookup'
-require 'liquid/file_system'
require 'liquid/resource_limits'
+require 'liquid/expression'
require 'liquid/template'
-require 'liquid/standardfilters'
require 'liquid/condition'
+require 'liquid/range_slice'
require 'liquid/utils'
require 'liquid/tokenizer'
require 'liquid/parse_context'
@@ -86,7 +91,3 @@ module Liquid
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
-
-# Load all the tags of the standard library
-#
-Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f }
diff --git a/lib/liquid/block.rb b/lib/liquid/block.rb
index dc22a4772..73d86c7bd 100644
--- a/lib/liquid/block.rb
+++ b/lib/liquid/block.rb
@@ -36,13 +36,17 @@ def unknown_tag(tag_name, _markup, _tokenizer)
# @api private
def self.raise_unknown_tag(tag, block_name, block_delimiter, parse_context)
if tag == 'else'
- raise SyntaxError, parse_context.locale.t("errors.syntax.unexpected_else",
- block_name: block_name)
+ raise SyntaxError, parse_context.locale.t(
+ "errors.syntax.unexpected_else",
+ block_name: block_name,
+ )
elsif tag.start_with?('end')
- raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_delimiter",
+ raise SyntaxError, parse_context.locale.t(
+ "errors.syntax.invalid_delimiter",
tag: tag,
block_name: block_name,
- block_delimiter: block_delimiter)
+ block_delimiter: block_delimiter,
+ )
else
raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag)
end
diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb
index a0d35a790..90e7c8237 100644
--- a/lib/liquid/block_body.rb
+++ b/lib/liquid/block_body.rb
@@ -6,6 +6,7 @@ module Liquid
class BlockBody
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
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
WhitespaceOrNothing = /\A\s*\z/
TAGSTART = "{%"
@@ -45,7 +46,13 @@ def freeze
end
tag_name = Regexp.last_match(1)
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
# determine how to proceed
return yield tag_name, markup
@@ -92,7 +99,9 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
context.handle_error(exc, line_number)
else
error_message = context.handle_error(exc, line_number)
- unless blank_tag # conditional for backwards compatibility
+ error_mode = context.registers.static[:template_error_mode]
+ 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
end
end
@@ -109,14 +118,22 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
end
end
- private def parse_for_document(tokenizer, parse_context)
+ private def handle_invalid_tag_token(token, parse_context)
+ if token.end_with?('%}')
+ yield token, token
+ else
+ BlockBody.raise_missing_tag_terminator(token, parse_context)
+ end
+ end
+
+ private def parse_for_document(tokenizer, parse_context, &block)
while (token = tokenizer.shift)
next if token.empty?
case
when token.start_with?(TAGSTART)
whitespace_handler(token, parse_context)
unless token =~ FullToken
- BlockBody.raise_missing_tag_terminator(token, parse_context)
+ return handle_invalid_tag_token(token, parse_context, &block)
end
tag_name = Regexp.last_match(2)
markup = Regexp.last_match(4)
@@ -132,7 +149,7 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
next
end
- unless (tag = registered_tags[tag_name])
+ unless (tag = parse_context.environment.tag_for_name(tag_name))
# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup
@@ -231,10 +248,17 @@ def render_node(context, output, node)
end
def create_variable(token, parse_context)
- if token =~ ContentOfVariable
- markup = Regexp.last_match(1)
+ if token.end_with?("}}")
+ i = 2
+ 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)
end
+
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
@@ -247,9 +271,5 @@ def raise_missing_tag_terminator(token, parse_context)
def raise_missing_variable_terminator(token, parse_context)
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
-
- def registered_tags
- Template.tags
- end
end
end
diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb
index 595f1713b..9d55c42b3 100644
--- a/lib/liquid/condition.rb
+++ b/lib/liquid/condition.rb
@@ -24,6 +24,9 @@ class Condition # :nodoc:
else
false
end
+ rescue Encoding::CompatibilityError
+ # "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
+ left.b.include?(right.b)
end,
}
@@ -45,8 +48,8 @@ def self.operators
@@operators
end
- def self.parse_expression(parse_context, markup)
- @@method_literals[markup] || parse_context.parse_expression(markup)
+ def self.parse_expression(parse_context, markup, safe: false)
+ @@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
end
attr_reader :attachment, :child_condition
@@ -69,9 +72,9 @@ def evaluate(context = deprecated_default_context)
case condition.child_relation
when :or
- break if result
+ break if Liquid::Utils.to_liquid_value(result)
when :and
- break unless result
+ break unless Liquid::Utils.to_liquid_value(result)
else
break
end
@@ -110,22 +113,65 @@ def inspect
def equal_variables(left, right)
if left.is_a?(MethodLiteral)
- if right.respond_to?(left.method_name)
- return right.send(left.method_name)
- else
- return nil
- end
+ return call_method_literal(left, right)
end
if right.is_a?(MethodLiteral)
- if left.respond_to?(right.method_name)
- return left.send(right.method_name)
+ return call_method_literal(right, left)
+ end
+
+ left == right
+ 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
- return nil
+ false
end
end
+ end
- left == right
+ # 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)
@@ -151,16 +197,18 @@ def interpret_condition(left, right, op, context)
end
def deprecated_default_context
- warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
- " and will be removed from Liquid 6.0.0.")
+ warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \
+ "and will be removed from Liquid 6.0.0.")
Context.new
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
- @node.left, @node.right,
- @node.child_condition, @node.attachment
+ @node.left,
+ @node.right,
+ @node.child_condition,
+ @node.attachment
].compact
end
end
diff --git a/lib/liquid/const.rb b/lib/liquid/const.rb
new file mode 100644
index 000000000..607f51a5b
--- /dev/null
+++ b/lib/liquid/const.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Liquid
+ module Const
+ EMPTY_HASH = {}.freeze
+ EMPTY_ARRAY = [].freeze
+ end
+end
diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb
index 36d3868b6..30492578e 100644
--- a/lib/liquid/context.rb
+++ b/lib/liquid/context.rb
@@ -15,35 +15,40 @@ module Liquid
# context['bob'] #=> nil class Context
class Context
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
+ attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :environment
# rubocop:disable Metrics/ParameterLists
- 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, &block)
+ def self.build(environment: Environment.default, 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)
end
- def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {})
+ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default)
+ @environment = environment
@environments = [environments]
@environments.flatten!
@static_environments = [static_environments].flatten(1).freeze
- @scopes = [(outer_scope || {})]
+ @scopes = [outer_scope || {}]
@registers = registers.is_a?(Registers) ? registers : Registers.new(registers)
@errors = []
@partial = false
@strict_variables = false
- @resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
+ @resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits)
@base_scope_depth = 0
@interrupts = []
@filters = []
@global_filter = nil
@disabled_tags = {}
+ # Instead of constructing new StringScanner objects for each Expression parse,
+ # we recycle the same one.
+ @string_scanner = StringScanner.new("")
+
@registers.static[:cached_partials] ||= {}
- @registers.static[:file_system] ||= Liquid::Template.file_system
+ @registers.static[:file_system] ||= environment.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new
- self.exception_renderer = Template.default_exception_renderer
+ self.exception_renderer = environment.exception_renderer
if rethrow_errors
self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA
end
@@ -60,7 +65,7 @@ def warnings
end
def strainer
- @strainer ||= StrainerFactory.create(self, @filters)
+ @strainer ||= @environment.create_strainer(self, @filters)
end
# Adds filters to this context.
@@ -142,9 +147,10 @@ def new_isolated_subcontext
check_overflow
self.class.build(
+ environment: @environment,
resource_limits: resource_limits,
static_environments: static_environments,
- registers: Registers.new(registers)
+ registers: Registers.new(registers),
).tap do |subcontext|
subcontext.base_scope_depth = base_scope_depth + 1
subcontext.exception_renderer = exception_renderer
@@ -174,11 +180,20 @@ def []=(key, value)
# Example:
# products == empty #=> products.empty?
def [](expression)
- evaluate(Expression.parse(expression))
+ evaluate(Expression.parse(expression, @string_scanner))
end
def key?(key)
- self[key] != nil
+ find_variable(key, raise_on_not_found: false) != 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
def evaluate(object)
@@ -191,16 +206,29 @@ def find_variable(key, raise_on_not_found: true)
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
+ fallback_to_self_drop = key == Expression::SELF && index.nil?
+
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else
- try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
+ try_variable_find_in_environments(
+ key,
+ raise_on_not_found: raise_on_not_found && !fallback_to_self_drop,
+ )
end
- variable = variable.to_liquid
+ # `self` resolves to a SelfDrop (enabling `self['var']` lookups),
+ # 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
+ liquid_variable = variable.to_liquid
+
+ liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
+
+ liquid_variable
end
def lookup_and_evaluate(obj, key, raise_on_not_found: true)
diff --git a/lib/liquid/deprecations.rb b/lib/liquid/deprecations.rb
new file mode 100644
index 000000000..e1ec628f4
--- /dev/null
+++ b/lib/liquid/deprecations.rb
@@ -0,0 +1,22 @@
+# 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
diff --git a/lib/liquid/drop.rb b/lib/liquid/drop.rb
index d4d8950f2..d13398ec4 100644
--- a/lib/liquid/drop.rb
+++ b/lib/liquid/drop.rb
@@ -25,9 +25,13 @@ module Liquid
class Drop
attr_writer :context
+ def initialize
+ @context = nil
+ end
+
# Catch all for the method
def liquid_method_missing(method)
- return nil unless @context&.strict_variables
+ return unless @context&.strict_variables
raise Liquid::UndefinedDropMethod, "undefined method #{method}"
end
diff --git a/lib/liquid/environment.rb b/lib/liquid/environment.rb
new file mode 100644
index 000000000..100bd0bbd
--- /dev/null
+++ b/lib/liquid/environment.rb
@@ -0,0 +1,159 @@
+# 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] 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] 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] 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
diff --git a/lib/liquid/errors.rb b/lib/liquid/errors.rb
index 832341d76..086fabb19 100644
--- a/lib/liquid/errors.rb
+++ b/lib/liquid/errors.rb
@@ -40,19 +40,20 @@ def message_prefix
end
end
- ArgumentError = Class.new(Error)
- ContextError = Class.new(Error)
- FileSystemError = Class.new(Error)
- StandardError = Class.new(Error)
- SyntaxError = Class.new(Error)
- StackLevelError = Class.new(Error)
- MemoryError = Class.new(Error)
- ZeroDivisionError = Class.new(Error)
- FloatDomainError = Class.new(Error)
- UndefinedVariable = Class.new(Error)
- UndefinedDropMethod = Class.new(Error)
- UndefinedFilter = Class.new(Error)
- MethodOverrideError = Class.new(Error)
- DisabledError = Class.new(Error)
- InternalError = Class.new(Error)
+ ArgumentError = Class.new(Error)
+ ContextError = Class.new(Error)
+ FileSystemError = Class.new(Error)
+ StandardError = Class.new(Error)
+ SyntaxError = Class.new(Error)
+ StackLevelError = Class.new(Error)
+ MemoryError = Class.new(Error)
+ ZeroDivisionError = Class.new(Error)
+ FloatDomainError = Class.new(Error)
+ UndefinedVariable = Class.new(Error)
+ UndefinedDropMethod = Class.new(Error)
+ UndefinedFilter = Class.new(Error)
+ MethodOverrideError = Class.new(Error)
+ DisabledError = Class.new(Error)
+ InternalError = Class.new(Error)
+ TemplateEncodingError = Class.new(Error)
end
diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb
index fcdd4d8ae..7e15f85b0 100644
--- a/lib/liquid/expression.rb
+++ b/lib/liquid/expression.rb
@@ -2,42 +2,127 @@
module Liquid
class Expression
+ SELF = 'self'
+
LITERALS = {
- nil => nil, 'nil' => nil, 'null' => nil, '' => nil,
+ nil => nil,
+ 'nil' => nil,
+ 'null' => nil,
+ '' => nil,
'true' => true,
'false' => false,
'blank' => '',
- 'empty' => ''
+ 'empty' => '',
+ # in lax mode, minus sign can be a VariableLookup
+ # For simplicity and performace, we treat it like a literal
+ '-' => VariableLookup.parse("-", nil).freeze,
}.freeze
- INTEGERS_REGEX = /\A(-?\d+)\z/
- FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
+ DOT = ".".ord
+ ZERO = "0".ord
+ NINE = "9".ord
+ DASH = "-".ord
# Use an atomic group (?>...) to avoid pathological backtracing from
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
- RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
+ RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
+ INTEGER_REGEX = /\A(-?\d+)\z/
+ FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
+
+ class << self
+ def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
+ parse(parser.expression, ss, cache)
+ end
+
+ def parse(markup, ss = StringScanner.new(""), cache = nil)
+ return unless markup
+
+ markup = markup.strip # markup can be a frozen string
+
+ if (markup.start_with?('"') && markup.end_with?('"')) ||
+ (markup.start_with?("'") && markup.end_with?("'"))
+ return markup[1..-2]
+ elsif LITERALS.key?(markup)
+ return LITERALS[markup]
+ end
- def self.parse(markup)
- return nil unless markup
+ # Cache only exists during parsing
+ if cache
+ return cache[markup] if cache.key?(markup)
- markup = markup.strip
- if (markup.start_with?('"') && markup.end_with?('"')) ||
- (markup.start_with?("'") && markup.end_with?("'"))
- return markup[1..-2]
+ cache[markup] = inner_parse(markup, ss, cache).freeze
+ else
+ inner_parse(markup, ss, nil).freeze
+ 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
- case markup
- when INTEGERS_REGEX
- Regexp.last_match(1).to_i
- when RANGES_REGEX
- RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2))
- when FLOATS_REGEX
- Regexp.last_match(1).to_f
- else
- if LITERALS.key?(markup)
- LITERALS[markup]
+ def 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
- VariableLookup.parse(markup)
+ # number ends with a dot "123."
+ markup.byteslice(0, first_dot_pos).to_f
end
end
end
diff --git a/lib/liquid/forloop_drop.rb b/lib/liquid/forloop_drop.rb
index 9592faa14..13681b2df 100644
--- a/lib/liquid/forloop_drop.rb
+++ b/lib/liquid/forloop_drop.rb
@@ -5,7 +5,7 @@ module Liquid
# @liquid_type object
# @liquid_name forloop
# @liquid_summary
- # Information about a parent [`for` loop](/api/liquid/tags#for).
+ # Information about a parent [`for` loop](/docs/api/liquid/tags/for).
class ForloopDrop < Drop
def initialize(name, length, parentloop)
@name = name
@@ -30,10 +30,7 @@ def initialize(name, length, parentloop)
# @liquid_return [forloop]
attr_reader :parentloop
- def name
- Usage.increment('forloop_drop_name')
- @name
- end
+ attr_reader :name
# @liquid_public_docs
# @liquid_summary
diff --git a/lib/liquid/i18n.rb b/lib/liquid/i18n.rb
index 4a2885e86..982688115 100644
--- a/lib/liquid/i18n.rb
+++ b/lib/liquid/i18n.rb
@@ -28,7 +28,7 @@ def locale
def interpolate(name, vars)
name.gsub(/%\{(\w+)\}/) do
# 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
diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb
index 4ce2bc7b9..f1740dbad 100644
--- a/lib/liquid/lexer.rb
+++ b/lib/liquid/lexer.rb
@@ -1,61 +1,179 @@
# frozen_string_literal: true
-require "strscan"
module Liquid
class Lexer
- SPECIALS = {
- '|' => :pipe,
- '.' => :dot,
- ':' => :colon,
- ',' => :comma,
- '[' => :open_square,
- ']' => :close_square,
- '(' => :open_round,
- ')' => :close_round,
- '?' => :question,
- '-' => :dash,
- }.freeze
- IDENTIFIER = /[a-zA-Z_][\w-]*\??/
- SINGLE_STRING_LITERAL = /'[^\']*'/
+ CLOSE_ROUND = [:close_round, ")"].freeze
+ CLOSE_SQUARE = [:close_square, "]"].freeze
+ COLON = [:colon, ":"].freeze
+ COMMA = [:comma, ","].freeze
+ COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze
+ COMPARISON_CONTAINS = [:comparison, "contains"].freeze
+ COMPARISON_EQUAL = [:comparison, "=="].freeze
+ COMPARISON_GREATER_THAN = [:comparison, ">"].freeze
+ COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze
+ COMPARISON_LESS_THAN = [:comparison, "<"].freeze
+ COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze
+ COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
+ DASH = [:dash, "-"].freeze
+ DOT = [:dot, "."].freeze
+ DOTDOT = [:dotdot, ".."].freeze
+ DOT_ORD = ".".ord
DOUBLE_STRING_LITERAL = /"[^\"]*"/
- STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
+ EOS = [:end_of_string].freeze
+ IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
- DOTDOT = /\.\./
- COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
+ 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 = /'[^\']*'/
WHITESPACE_OR_NOTHING = /\s*/
- def initialize(input)
- @ss = StringScanner.new(input)
+ SINGLE_COMPARISON_TOKENS = [].tap do |table|
+ table["<".ord] = COMPARISON_LESS_THAN
+ table[">".ord] = COMPARISON_GREATER_THAN
+ table.freeze
end
- def tokenize
- @output = []
-
- until @ss.eos?
- @ss.skip(WHITESPACE_OR_NOTHING)
- break if @ss.eos?
- tok = if (t = @ss.scan(COMPARISON_OPERATOR))
- [:comparison, t]
- elsif (t = @ss.scan(STRING_LITERAL))
- [:string, t]
- elsif (t = @ss.scan(NUMBER_LITERAL))
- [:number, t]
- elsif (t = @ss.scan(IDENTIFIER))
- [:id, t]
- elsif (t = @ss.scan(DOTDOT))
- [:dotdot, t]
- else
- c = @ss.getch
- if (s = SPECIALS[c])
- [s, c]
+ TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
+ table["=".ord] = [].tap do |sub_table|
+ 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|
+ table["<".ord] = [].tap do |sub_table|
+ sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
+ sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
+ sub_table.freeze
+ end
+ table[">".ord] = [].tap do |sub_table|
+ sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
+ sub_table.freeze
+ end
+ table.freeze
+ end
+
+ NEXT_MATCHER_JUMP_TABLE = [].tap do |table|
+ "a".upto("z") do |c|
+ table[c.ord] = [:id, IDENTIFIER].freeze
+ table[c.upcase.ord] = [:id, IDENTIFIER].freeze
+ 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
+ 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
- raise SyntaxError, "Unexpected character #{c}"
+ type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
+
+ if type && (t = ss.scan(pattern))
+ # Special case for "contains"
+ output << if type == :id && t == "contains" && output.last&.first != :dot
+ COMPARISON_CONTAINS
+ else
+ [type, t]
+ end
+ else
+ raise_syntax_error(start_pos, ss)
+ end
end
end
- @output << tok
+ # rubocop:enable Metrics/BlockNesting
+ output << EOS
+ rescue ::ArgumentError => e
+ if e.message == "invalid byte sequence in #{ss.string.encoding}"
+ raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
+ else
+ raise
+ end
end
- @output << [:end_of_string]
+ def raise_syntax_error(start_pos, ss)
+ ss.pos = start_pos
+ # the character could be a UTF-8 character, use getch to get all the bytes
+ raise SyntaxError, "Unexpected character #{ss.getch}"
+ end
end
end
end
diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml
index 7e232de42..b99d490c8 100644
--- a/lib/liquid/locales/en.yml
+++ b/lib/liquid/locales/en.yml
@@ -2,12 +2,14 @@
errors:
syntax:
tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}"
+ block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}"
assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
case: "Syntax Error in 'case' - Valid syntax: case [condition]"
case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}"
case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) "
cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"
+ doc_invalid_nested: "Syntax Error in 'doc' - Nested doc tags are not allowed"
for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"
for_invalid_in: "For loops require an 'in' clause"
for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset"
@@ -15,8 +17,10 @@
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"
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"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
+ table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
tag_never_closed: "'%{block_name}' tag was never closed"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
unexpected_else: "%{block_name} tag does not expect 'else' tag"
diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb
index 87570ad52..d956d1c61 100644
--- a/lib/liquid/parse_context.rb
+++ b/lib/liquid/parse_context.rb
@@ -3,14 +3,27 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
- attr_reader :partial, :warnings, :error_mode
+ attr_reader :partial, :warnings, :error_mode, :environment
- def initialize(options = {})
+ def initialize(options = Const::EMPTY_HASH)
+ @environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new
@warnings = []
+ # constructing new StringScanner in Lexer, Tokenizer, etc is expensive
+ # This StringScanner will be shared by all of them
+ @string_scanner = StringScanner.new("")
+
+ @expression_cache = if options[:expression_cache].nil?
+ {}
+ elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=)
+ options[:expression_cache]
+ elsif options[:expression_cache]
+ {}
+ end
+
self.depth = 0
self.partial = false
end
@@ -23,19 +36,44 @@ def new_block_body
Liquid::BlockBody.new
end
- def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false)
- Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag)
+ def new_parser(input)
+ @string_scanner.string = input
+ Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid)
+ end
+
+ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
+ Tokenizer.new(
+ source: source,
+ string_scanner: @string_scanner,
+ line_number: start_line_number,
+ for_liquid_tag: for_liquid_tag,
+ )
end
- def parse_expression(markup)
- Expression.parse(markup)
+ 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 a string returned by the parser
+ # (e.g., parser.expression). We're not calling the parser here to
+ # prevent redundant parser overhead.
+ raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
+ end
+
+ Expression.parse(markup, @string_scanner, @expression_cache)
end
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
- @error_mode = @options[:error_mode] || Template.error_mode
+ @error_mode = @options[:error_mode] || @environment.error_mode
end
def partial_options
diff --git a/lib/liquid/parse_tree_visitor.rb b/lib/liquid/parse_tree_visitor.rb
index c9c6646e1..6d23b2c50 100644
--- a/lib/liquid/parse_tree_visitor.rb
+++ b/lib/liquid/parse_tree_visitor.rb
@@ -36,7 +36,7 @@ def visit(context = nil)
protected
def children
- @node.respond_to?(:nodelist) ? Array(@node.nodelist) : []
+ @node.respond_to?(:nodelist) ? Array(@node.nodelist) : Const::EMPTY_ARRAY
end
end
end
diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb
index 609601ac4..7a2136052 100644
--- a/lib/liquid/parser.rb
+++ b/lib/liquid/parser.rb
@@ -2,10 +2,11 @@
module Liquid
class Parser
- def initialize(input)
- l = Lexer.new(input)
- @tokens = l.tokenize
+ def initialize(input, reject_bare_brackets: false)
+ ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
+ @tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location
+ @reject_bare_brackets = reject_bare_brackets
end
def jump(point)
@@ -53,7 +54,10 @@ def expression
str = consume
str << variable_lookups
when :open_square
- str = consume
+ if @reject_bare_brackets
+ raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead"
+ end
+ str = consume.dup
str << expression
str << consume(:close_square)
str << variable_lookups
diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb
index 78afd58a9..e419dc999 100644
--- a/lib/liquid/parser_switching.rb
+++ b/lib/liquid/parser_switching.rb
@@ -2,10 +2,25 @@
module Liquid
module ParserSwitching
+ # Do not use this.
+ #
+ # It's basically doing the same thing the {#parse_with_selected_parser},
+ # except this will try the strict parser regardless of the error mode,
+ # and fall back to the lax parser if the error mode is lax or warn,
+ # except when in strict2 mode where it uses the strict2 parser.
+ #
+ # @deprecated Use {#parse_with_selected_parser} instead.
def strict_parse_with_error_mode_fallback(markup)
+ return strict2_parse_with_error_context(markup) if strict2_mode?
+
strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
+ when :rigid
+ rigid_warn
+ raise
+ when :strict2
+ raise
when :strict
raise
when :warn
@@ -16,11 +31,13 @@ def strict_parse_with_error_mode_fallback(markup)
def parse_with_selected_parser(markup)
case parse_context.error_mode
- when :strict then strict_parse_with_error_context(markup)
- when :lax then lax_parse(markup)
+ when :rigid then rigid_warn && strict2_parse_with_error_context(markup)
+ when :strict2 then strict2_parse_with_error_context(markup)
+ when :strict then strict_parse_with_error_context(markup)
+ when :lax then lax_parse(markup)
when :warn
begin
- strict_parse_with_error_context(markup)
+ strict2_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
@@ -28,8 +45,24 @@ def parse_with_selected_parser(markup)
end
end
+ def strict2_mode?
+ parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
+ end
+
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)
strict_parse(markup)
rescue SyntaxError => e
diff --git a/lib/liquid/partial_cache.rb b/lib/liquid/partial_cache.rb
index 56e108050..f49d14d90 100644
--- a/lib/liquid/partial_cache.rb
+++ b/lib/liquid/partial_cache.rb
@@ -4,7 +4,8 @@ module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials]
- cached = cached_partials[template_name]
+ cache_key = "#{template_name}:#{parse_context.error_mode}"
+ cached = cached_partials[cache_key]
return cached if cached
file_system = context.registers[:file_system]
@@ -15,8 +16,16 @@ def self.load(template_name, context:, parse_context:)
template_factory = context.registers[:template_factory]
template = template_factory.for(template_name)
- partial = template.parse(source, parse_context)
- cached_partials[template_name] = partial
+ begin
+ partial = template.parse(source, parse_context)
+ rescue Liquid::Error => e
+ e.template_name = template&.name || template_name
+ raise e
+ end
+
+ partial.name ||= template_name
+
+ cached_partials[cache_key] = partial
ensure
parse_context.partial = false
end
diff --git a/lib/liquid/range_lookup.rb b/lib/liquid/range_lookup.rb
index fd208a676..bc316fe12 100644
--- a/lib/liquid/range_lookup.rb
+++ b/lib/liquid/range_lookup.rb
@@ -2,9 +2,9 @@
module Liquid
class RangeLookup
- def self.parse(start_markup, end_markup)
- start_obj = Expression.parse(start_markup)
- end_obj = Expression.parse(end_markup)
+ def self.parse(start_markup, end_markup, string_scanner, cache = nil)
+ start_obj = Expression.parse(start_markup, string_scanner, cache)
+ end_obj = Expression.parse(end_markup, string_scanner, cache)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
diff --git a/lib/liquid/range_slice.rb b/lib/liquid/range_slice.rb
new file mode 100644
index 000000000..a966b112c
--- /dev/null
+++ b/lib/liquid/range_slice.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Liquid
+ class RangeSlice
+ attr_reader :length
+
+ def initialize(range, from, to, resource_limits)
+ range_length = range.end - range.begin
+ range_length += 1 unless range.exclude_end?
+ range_length = 0 if range_length.negative?
+
+ start = [from, 0].max
+ finish = [to || range_length, range_length].min
+
+ @first = range.begin + start
+ @length = [finish - start, 0].max
+ @direction = 1
+ @resource_limits = resource_limits
+ end
+
+ def empty?
+ @length.zero?
+ end
+
+ def each
+ return enum_for(:each) unless block_given?
+
+ value = @first
+ @length.times do
+ @resource_limits.increment_render_score(1)
+ yield value
+ value += @direction
+ end
+ end
+
+ def reverse!
+ unless empty?
+ @first += @direction * (@length - 1)
+ @direction = -@direction
+ end
+ self
+ end
+ end
+end
diff --git a/lib/liquid/resource_limits.rb b/lib/liquid/resource_limits.rb
index 70fac24be..ee0c66cbb 100644
--- a/lib/liquid/resource_limits.rb
+++ b/lib/liquid/resource_limits.rb
@@ -2,24 +2,39 @@
module Liquid
class ResourceLimits
- attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
- attr_reader :render_score, :assign_score
+ attr_accessor :render_length_limit,
+ :render_score_limit,
+ :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)
- @render_length_limit = limits[:render_length_limit]
- @render_score_limit = limits[:render_score_limit]
- @assign_score_limit = limits[:assign_score_limit]
+ @render_length_limit = limits[:render_length_limit]
+ @render_score_limit = limits[:render_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
end
def increment_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 @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
end
def increment_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 @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end
# update either render_length or assign_score based on whether or not the writes are captured
@@ -47,6 +62,8 @@ def reset
@reached_limit = false
@last_capture_length = nil
@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
def with_capture
diff --git a/lib/liquid/self_drop.rb b/lib/liquid/self_drop.rb
new file mode 100644
index 000000000..b753f383b
--- /dev/null
+++ b/lib/liquid/self_drop.rb
@@ -0,0 +1,54 @@
+# 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
diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb
index ed7136abf..ed6141566 100644
--- a/lib/liquid/standardfilters.rb
+++ b/lib/liquid/standardfilters.rb
@@ -3,10 +3,25 @@
require 'cgi'
require 'base64'
require 'bigdecimal'
-
module Liquid
module StandardFilters
- MAX_INT = (1 << 31) - 1
+ MAX_I32 = (1 << 31) - 1
+ private_constant :MAX_I32
+
+ supports_64bit_indices = begin
+ [][1 << 33, 1 << 33]
+ true
+ rescue RangeError
+ 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 = {
'&' => '&',
'>' => '>',
@@ -18,10 +33,23 @@ module StandardFilters
STRIP_HTML_BLOCKS = Regexp.union(
%r{}m,
//m,
- %r{}m
+ %r{}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_type filter
# @liquid_category array
@@ -44,7 +72,7 @@ def size(input)
# @liquid_syntax string | downcase
# @liquid_return [string]
def downcase(input)
- input.to_s.downcase
+ Utils.to_s(input).downcase
end
# @liquid_public_docs
@@ -55,29 +83,29 @@ def downcase(input)
# @liquid_syntax string | upcase
# @liquid_return [string]
def upcase(input)
- input.to_s.upcase
+ Utils.to_s(input).upcase
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
# @liquid_summary
- # Capitalizes the first word in a string.
+ # Capitalizes the first word in a string and downcases the remaining characters.
# @liquid_syntax string | capitalize
# @liquid_return [string]
def capitalize(input)
- input.to_s.capitalize
+ Utils.to_s(input).capitalize
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
# @liquid_summary
- # Escapes a string.
+ # Escapes special characters in HTML, such as `<>`, `'`, and `&`, and converts characters into escape sequences. The filter doesn't effect characters within the string that don’t have a corresponding escape sequence.".
# @liquid_syntax string | escape
# @liquid_return [string]
def escape(input)
- CGI.escapeHTML(input.to_s) unless input.nil?
+ CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
end
alias_method :h, :escape
@@ -89,7 +117,7 @@ def escape(input)
# @liquid_syntax string | escape_once
# @liquid_return [string]
def escape_once(input)
- input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
+ Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
end
# @liquid_public_docs
@@ -104,7 +132,7 @@ def escape_once(input)
# @liquid_syntax string | url_encode
# @liquid_return [string]
def url_encode(input)
- CGI.escape(input.to_s) unless input.nil?
+ CGI.escape(Utils.to_s(input)) unless input.nil?
end
# @liquid_public_docs
@@ -118,7 +146,7 @@ def url_encode(input)
def url_decode(input)
return if input.nil?
- result = CGI.unescape(input.to_s)
+ result = CGI.unescape(Utils.to_s(input))
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
result
@@ -132,7 +160,7 @@ def url_decode(input)
# @liquid_syntax string | base64_encode
# @liquid_return [string]
def base64_encode(input)
- Base64.strict_encode64(input.to_s)
+ Base64.strict_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -143,7 +171,8 @@ def base64_encode(input)
# @liquid_syntax string | base64_decode
# @liquid_return [string]
def base64_decode(input)
- Base64.strict_decode64(input.to_s)
+ input = Utils.to_s(input)
+ StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
end
@@ -156,7 +185,7 @@ def base64_decode(input)
# @liquid_syntax string | base64_url_safe_encode
# @liquid_return [string]
def base64_url_safe_encode(input)
- Base64.urlsafe_encode64(input.to_s)
+ Base64.urlsafe_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -167,7 +196,8 @@ def base64_url_safe_encode(input)
# @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string]
def base64_url_safe_decode(input)
- Base64.urlsafe_decode64(input.to_s)
+ input = Utils.to_s(input)
+ StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
end
@@ -186,10 +216,19 @@ def slice(input, offset, length = nil)
offset = Utils.to_integer(offset)
length = length ? Utils.to_integer(length) : 1
- if input.is_a?(Array)
- input.slice(offset, length) || []
- else
- input.to_s.slice(offset, length) || ''
+ begin
+ if input.is_a?(Array)
+ input.slice(offset, length) || []
+ else
+ Utils.to_s(input).slice(offset, length) || ''
+ end
+ rescue RangeError
+ if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
+ raise # unexpected error
+ end
+ offset = offset.clamp(INDEX_RANGE)
+ length = length.clamp(INDEX_RANGE)
+ retry
end
end
@@ -205,10 +244,10 @@ def slice(input, offset, length = nil)
# @liquid_return [string]
def truncate(input, length = 50, truncate_string = "...")
return if input.nil?
- input_str = input.to_s
+ input_str = Utils.to_s(input)
length = Utils.to_integer(length)
- truncate_string_str = truncate_string.to_s
+ truncate_string_str = Utils.to_s(truncate_string)
l = length - truncate_string_str.length
l = 0 if l < 0
@@ -232,21 +271,22 @@ def truncate(input, length = 50, truncate_string = "...")
# @liquid_return [string]
def truncatewords(input, words = 15, truncate_string = "...")
return if input.nil?
- input = input.to_s
+ input = Utils.to_s(input)
words = Utils.to_integer(words)
words = 1 if words <= 0
wordlist = begin
input.split(" ", words + 1)
rescue RangeError
- raise if words + 1 < MAX_INT
- # e.g. integer #{words} too big to convert to `int'
- raise Liquid::ArgumentError, "integer #{words} too big for truncatewords"
+ # integer too big for String#split, but we can semantically assume no truncation is needed
+ return input if words + 1 > MAX_I32
+ raise # unexpected error
end
return input if wordlist.length <= words
wordlist.pop
- wordlist.join(" ").concat(truncate_string.to_s)
+ truncate_string = Utils.to_s(truncate_string)
+ wordlist.join(" ").concat(truncate_string)
end
# @liquid_public_docs
@@ -257,7 +297,22 @@ def truncatewords(input, words = 15, truncate_string = "...")
# @liquid_syntax string | split: string
# @liquid_return [array[string]]
def split(input, pattern)
- input.to_s.split(pattern.to_s)
+ pattern = Utils.to_s(pattern)
+ input = Utils.to_s(input)
+ input.split(pattern)
+ end
+
+ # @liquid_public_docs
+ # @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
# @liquid_public_docs
@@ -268,7 +323,8 @@ def split(input, pattern)
# @liquid_syntax string | strip
# @liquid_return [string]
def strip(input)
- input.to_s.strip
+ input = Utils.to_s(input)
+ input.strip
end
# @liquid_public_docs
@@ -279,7 +335,8 @@ def strip(input)
# @liquid_syntax string | lstrip
# @liquid_return [string]
def lstrip(input)
- input.to_s.lstrip
+ input = Utils.to_s(input)
+ input.lstrip
end
# @liquid_public_docs
@@ -290,7 +347,8 @@ def lstrip(input)
# @liquid_syntax string | rstrip
# @liquid_return [string]
def rstrip(input)
- input.to_s.rstrip
+ input = Utils.to_s(input)
+ input.rstrip
end
# @liquid_public_docs
@@ -301,8 +359,9 @@ def rstrip(input)
# @liquid_syntax string | strip_html
# @liquid_return [string]
def strip_html(input)
+ input = Utils.to_s(input)
empty = ''
- result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
+ result = input.gsub(STRIP_HTML_BLOCKS, empty)
result.gsub!(STRIP_HTML_TAGS, empty)
result
end
@@ -315,7 +374,8 @@ def strip_html(input)
# @liquid_syntax string | strip_newlines
# @liquid_return [string]
def strip_newlines(input)
- input.to_s.gsub(/\r?\n/, '')
+ input = Utils.to_s(input)
+ input.gsub(/\r?\n/, '')
end
# @liquid_public_docs
@@ -326,6 +386,7 @@ def strip_newlines(input)
# @liquid_syntax array | join
# @liquid_return [string]
def join(input, glue = ' ')
+ glue = Utils.to_s(glue)
InputIterator.new(input, context).join(glue)
end
@@ -393,29 +454,59 @@ def sort_natural(input, property = nil)
# @liquid_syntax array | where: string, string
# @liquid_return [array[untyped]]
def where(input, property, target_value = nil)
- ary = InputIterator.new(input, context)
+ filter_array(input, property, target_value) { |ary, &block| ary.select(&block) }
+ end
- if ary.empty?
- []
- elsif target_value.nil?
- ary.select do |item|
- item[property]
- rescue TypeError
- raise_property_error(property)
- rescue NoMethodError
- return nil unless item.respond_to?(:[])
- raise
- end
- else
- ary.select do |item|
- item[property] == target_value
- rescue TypeError
- raise_property_error(property)
- rescue NoMethodError
- return nil unless item.respond_to?(:[])
- raise
- end
- end
+ # @liquid_public_docs
+ # @liquid_type filter
+ # @liquid_category array
+ # @liquid_summary
+ # Filters an array to exclude items with a specific property value.
+ # @liquid_description
+ # This requires you to provide both the property name and the associated value.
+ # @liquid_syntax array | reject: string, string
+ # @liquid_return [array[untyped]]
+ def reject(input, property, target_value = nil)
+ filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) }
+ end
+
+ # @liquid_public_docs
+ # @liquid_type filter
+ # @liquid_category array
+ # @liquid_summary
+ # Tests if any item in an array has a specific property value.
+ # @liquid_description
+ # This requires you to provide both the property name and the associated value.
+ # @liquid_syntax array | has: string, string
+ # @liquid_return [boolean]
+ def has(input, property, target_value = nil)
+ filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) }
+ end
+
+ # @liquid_public_docs
+ # @liquid_type filter
+ # @liquid_category array
+ # @liquid_summary
+ # Returns the first item in an array with a specific property value.
+ # @liquid_description
+ # This requires you to provide both the property name and the associated value.
+ # @liquid_syntax array | find: string, string
+ # @liquid_return [untyped]
+ def find(input, property, target_value = nil)
+ filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) }
+ end
+
+ # @liquid_public_docs
+ # @liquid_type filter
+ # @liquid_category array
+ # @liquid_summary
+ # Returns the index of the first item in an array with a specific property value.
+ # @liquid_description
+ # This requires you to provide both the property name and the associated value.
+ # @liquid_syntax array | find_index: string, string
+ # @liquid_return [number]
+ def find_index(input, property, target_value = nil)
+ filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) }
end
# @liquid_public_docs
@@ -512,7 +603,10 @@ def compact(input, property = nil)
# @liquid_syntax string | replace: string, string
# @liquid_return [string]
def replace(input, string, replacement = '')
- input.to_s.gsub(string.to_s, replacement.to_s)
+ string = Utils.to_s(string)
+ replacement = Utils.to_s(replacement)
+ input = Utils.to_s(input)
+ input.gsub(string, replacement)
end
# @liquid_public_docs
@@ -523,7 +617,10 @@ def replace(input, string, replacement = '')
# @liquid_syntax string | replace_first: string, string
# @liquid_return [string]
def replace_first(input, string, replacement = '')
- input.to_s.sub(string.to_s, replacement.to_s)
+ string = Utils.to_s(string)
+ replacement = Utils.to_s(replacement)
+ input = Utils.to_s(input)
+ input.sub(string, replacement)
end
# @liquid_public_docs
@@ -534,9 +631,9 @@ def replace_first(input, string, replacement = '')
# @liquid_syntax string | replace_last: string, string
# @liquid_return [string]
def replace_last(input, string, replacement)
- input = input.to_s
- string = string.to_s
- replacement = replacement.to_s
+ input = Utils.to_s(input)
+ string = Utils.to_s(string)
+ replacement = Utils.to_s(replacement)
start_index = input.rindex(string)
@@ -588,7 +685,9 @@ def remove_last(input, string)
# @liquid_syntax string | append: string
# @liquid_return [string]
def append(input, string)
- input.to_s + string.to_s
+ input = Utils.to_s(input)
+ string = Utils.to_s(string)
+ input + string
end
# @liquid_public_docs
@@ -599,7 +698,7 @@ def append(input, string)
# @liquid_description
# > Note:
# > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the
- # > [`uniq` filter](/api/liquid/filters#uniq).
+ # > [`uniq` filter](/docs/api/liquid/filters/uniq).
# @liquid_syntax array | concat: array
# @liquid_return [array[untyped]]
def concat(input, array)
@@ -617,7 +716,9 @@ def concat(input, array)
# @liquid_syntax string | prepend: string
# @liquid_return [string]
def prepend(input, string)
- string.to_s + input.to_s
+ input = Utils.to_s(input)
+ string = Utils.to_s(string)
+ string + input
end
# @liquid_public_docs
@@ -628,10 +729,20 @@ def prepend(input, string)
# @liquid_syntax string | newline_to_br
# @liquid_return [string]
def newline_to_br(input)
- input.to_s.gsub(/\r?\n/, "
\n")
+ input = Utils.to_s(input)
+ input.gsub(/\r?\n/, "
\n")
end
- # Reformat a date using Ruby's core Time#strftime( string ) -> string
+ # @liquid_public_docs
+ # @liquid_type filter
+ # @liquid_category date
+ # @liquid_summary
+ # Formats a date according to a specified format string.
+ # @liquid_description
+ # This filter formats a date using various format specifiers. If the format string is empty,
+ # the original input is returned. If the input cannot be converted to a date, the original input is returned.
+ #
+ # The following format specifiers can be used:
#
# %a - The abbreviated weekday name (``Sun'')
# %A - The full weekday name (``Sunday'')
@@ -660,14 +771,15 @@ def newline_to_br(input)
# %Y - Year with century
# %Z - Time zone name
# %% - Literal ``%'' character
- #
- # See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
+ # @liquid_syntax date | date: string
+ # @liquid_return [string]
def date(input, format)
- return input if format.to_s.empty?
+ str_format = Utils.to_s(format)
+ return input if str_format.empty?
return input unless (date = Utils.to_date(input))
- date.strftime(format.to_s)
+ date.strftime(str_format)
end
# @liquid_public_docs
@@ -678,6 +790,8 @@ def date(input, format)
# @liquid_syntax array | first
# @liquid_return [untyped]
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)
end
@@ -689,6 +803,8 @@ def first(array)
# @liquid_syntax array | last
# @liquid_return [untyped]
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)
end
@@ -741,7 +857,7 @@ def times(input, operand)
# @liquid_type filter
# @liquid_category math
# @liquid_summary
- # Divides a number by a given number.
+ # Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float.
# @liquid_syntax number | divided_by: number
# @liquid_return [number]
def divided_by(input, operand)
@@ -841,24 +957,73 @@ def at_most(input, n)
# @liquid_summary
# Sets a default value for any variable whose value is one of the following:
#
- # - [`empty`](/api/liquid/basics#empty)
- # - [`false`](/api/liquid/basics#truthy-and-falsy)
- # - [`nil`](/api/liquid/basics#nil)
+ # - [`empty`](/docs/api/liquid/basics#empty)
+ # - [`false`](/docs/api/liquid/basics#truthy-and-falsy)
+ # - [`nil`](/docs/api/liquid/basics#nil)
# @liquid_syntax variable | default: variable
# @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 = {})
options = {} unless options.is_a?(Hash)
false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input)
false_check || (input.respond_to?(:empty?) && input.empty?) ? default_value : input
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
attr_reader :context
+ def filter_array(input, property, target_value, default_value = [], &block)
+ ary = InputIterator.new(input, context)
+
+ return default_value if ary.empty?
+
+ block.call(ary) do |item|
+ if target_value.nil?
+ item[property]
+ else
+ item[property] == target_value
+ end
+ rescue TypeError
+ raise_property_error(property)
+ rescue NoMethodError
+ return nil unless item.respond_to?(:[])
+ raise
+ end
+ end
+
def raise_property_error(property)
- raise Liquid::ArgumentError, "cannot select the property '#{property}'"
+ raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'"
end
def apply_operation(input, operand, operation)
@@ -883,6 +1048,8 @@ def nil_safe_compare(a, b)
def nil_safe_casecmp(a, b)
if !a.nil? && !b.nil?
a.to_s.casecmp(b.to_s)
+ elsif a.nil? && b.nil?
+ 0
else
a.nil? ? 1 : -1
end
@@ -905,7 +1072,18 @@ def initialize(input, context)
end
def join(glue)
- to_a.join(glue.to_s)
+ first = true
+ output = +""
+ each do |item|
+ if first
+ first = false
+ else
+ output << glue
+ end
+
+ output << Liquid::Utils.to_s(item)
+ end
+ output
end
def concat(args)
@@ -917,7 +1095,10 @@ def reverse
end
def uniq(&block)
- to_a.uniq(&block)
+ to_a.uniq do |item|
+ item = Utils.to_liquid_value(item)
+ block ? yield(item) : item
+ end
end
def compact
@@ -938,6 +1119,4 @@ def each
end
end
end
-
- Template.register_filter(StandardFilters)
end
diff --git a/lib/liquid/strainer_factory.rb b/lib/liquid/strainer_factory.rb
deleted file mode 100644
index bb51ef062..000000000
--- a/lib/liquid/strainer_factory.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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
diff --git a/lib/liquid/tablerowloop_drop.rb b/lib/liquid/tablerowloop_drop.rb
index b24e734a7..730d623a4 100644
--- a/lib/liquid/tablerowloop_drop.rb
+++ b/lib/liquid/tablerowloop_drop.rb
@@ -5,7 +5,7 @@ module Liquid
# @liquid_type object
# @liquid_name tablerowloop
# @liquid_summary
- # Information about a parent [`tablerow` loop](/api/liquid/tags#tablerow).
+ # Information about a parent [`tablerow` loop](/docs/api/liquid/tags/tablerow).
class TablerowloopDrop < Drop
def initialize(length, cols)
@length = length
diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb
index 085c0efc5..374ee511e 100644
--- a/lib/liquid/tag.rb
+++ b/lib/liquid/tag.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+require 'liquid/tag/disabler'
+require 'liquid/tag/disableable'
+
module Liquid
class Tag
attr_reader :nodelist, :tag_name, :line_number, :parse_context
@@ -14,12 +17,18 @@ def parse(tag_name, markup, tokenizer, parse_context)
end
def disable_tags(*tag_names)
- @disabled_tags ||= []
- @disabled_tags.concat(tag_names)
+ tag_names += disabled_tags
+ define_singleton_method(:disabled_tags) { tag_names }
prepend(Disabler)
end
private :new
+
+ protected
+
+ def disabled_tags
+ []
+ end
end
def initialize(tag_name, markup, parse_context)
@@ -48,7 +57,8 @@ def render(_context)
# of the `render_to_output_buffer` method will become the default and the `render`
# method will be removed.
def render_to_output_buffer(context, output)
- output << render(context)
+ render_result = render(context)
+ output << render_result if render_result
output
end
@@ -58,8 +68,12 @@ def blank?
private
- def parse_expression(markup)
- parse_context.parse_expression(markup)
+ def safe_parse_expression(parser)
+ parse_context.safe_parse_expression(parser)
+ end
+
+ def parse_expression(markup, safe: false)
+ parse_context.parse_expression(markup, safe: safe)
end
end
end
diff --git a/lib/liquid/tag/disabler.rb b/lib/liquid/tag/disabler.rb
index 210a177d7..852205abc 100644
--- a/lib/liquid/tag/disabler.rb
+++ b/lib/liquid/tag/disabler.rb
@@ -3,14 +3,6 @@
module Liquid
class Tag
module Disabler
- module ClassMethods
- attr_reader :disabled_tags
- end
-
- def self.prepended(base)
- base.extend(ClassMethods)
- end
-
def render_to_output_buffer(context, output)
context.with_disabled_tags(self.class.disabled_tags) do
super
diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb
new file mode 100644
index 000000000..dff7553f7
--- /dev/null
+++ b/lib/liquid/tags.rb
@@ -0,0 +1,49 @@
+# 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/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,
+ 'for' => For,
+ '#' => InlineComment,
+ 'break' => Break,
+ 'if' => If,
+ 'echo' => Echo,
+ 'tablerow' => TableRow,
+ 'doc' => Doc,
+ }.freeze
+ end
+end
diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb
index 7c0abd870..901db40f9 100644
--- a/lib/liquid/tags/assign.rb
+++ b/lib/liquid/tags/assign.rb
@@ -8,12 +8,18 @@ module Liquid
# @liquid_summary
# Creates a new variable.
# @liquid_description
- # You can create variables of any [basic type](/api/liquid/basics#types), [object](/api/liquid/objects), or object property.
+ # You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property.
+ #
+ # > Caution:
+ # > Predefined Liquid objects can be overridden by variables with the same name.
+ # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_syntax
# {% assign variable_name = value %}
# @liquid_syntax_keyword variable_name The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Assign < Tag
+ include ParserSwitching
+
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
# @api private
@@ -25,6 +31,10 @@ def self.raise_syntax_error(parse_context)
def initialize(tag_name, markup, parse_context)
super
+ parse_with_selected_parser(markup)
+ end
+
+ def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
@from = Variable.new(Regexp.last_match(2), parse_context)
@@ -33,6 +43,25 @@ def initialize(tag_name, markup, parse_context)
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)
val = @from.render(context)
context.scopes.last[@to] = val
@@ -72,6 +101,4 @@ def children
end
end
end
-
- Template.register_tag('assign', Assign)
end
diff --git a/lib/liquid/tags/break.rb b/lib/liquid/tags/break.rb
index ebdf1177c..cec640074 100644
--- a/lib/liquid/tags/break.rb
+++ b/lib/liquid/tags/break.rb
@@ -15,7 +15,7 @@ module Liquid
# @liquid_category iteration
# @liquid_name break
# @liquid_summary
- # Stops a [`for` loop](/api/liquid/tags#for) from iterating.
+ # Stops a [`for` loop](/docs/api/liquid/tags/for) from iterating.
# @liquid_syntax
# {% break %}
class Break < Tag
@@ -26,6 +26,4 @@ def render_to_output_buffer(context, output)
output
end
end
-
- Template.register_tag('break', Break)
end
diff --git a/lib/liquid/tags/capture.rb b/lib/liquid/tags/capture.rb
index 1b9f28193..5bebe20e4 100644
--- a/lib/liquid/tags/capture.rb
+++ b/lib/liquid/tags/capture.rb
@@ -9,6 +9,10 @@ module Liquid
# Creates a new variable with a string value.
# @liquid_description
# You can create complex strings with Liquid logic and variables.
+ #
+ # > Caution:
+ # > Predefined Liquid objects can be overridden by variables with the same name.
+ # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_syntax
# {% capture variable %}
# value
@@ -16,10 +20,18 @@ module Liquid
# @liquid_syntax_keyword variable The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Capture < Block
+ include ParserSwitching
+
Syntax = /(#{VariableSignature}+)/o
+ attr_reader :to
+
def initialize(tag_name, markup, options)
super
+ parse_with_selected_parser(markup)
+ end
+
+ def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
else
@@ -27,6 +39,16 @@ def initialize(tag_name, markup, options)
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)
context.resource_limits.with_capture do
capture_output = render(context)
@@ -39,6 +61,4 @@ def blank?
true
end
end
-
- Template.register_tag('capture', Capture)
end
diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb
index 910dd6eb5..e87e402e5 100644
--- a/lib/liquid/tags/case.rb
+++ b/lib/liquid/tags/case.rb
@@ -31,12 +31,7 @@ class Case < Block
def initialize(tag_name, markup, options)
super
@blocks = []
-
- if markup =~ Syntax
- @left = parse_expression(Regexp.last_match(1))
- else
- raise SyntaxError, options[:locale].t("errors.syntax.case")
- end
+ parse_with_selected_parser(markup)
end
def parse(tokens)
@@ -77,7 +72,7 @@ def render_to_output_buffer(context, output)
end
result = Liquid::Utils.to_liquid_value(
- block.evaluate(context)
+ block.evaluate(context),
)
if result
@@ -91,9 +86,50 @@ def render_to_output_buffer(context, output)
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)
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
unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
@@ -123,6 +159,4 @@ def children
end
end
end
-
- Template.register_tag('case', Case)
end
diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb
index 51cea5c39..659108e37 100644
--- a/lib/liquid/tags/comment.rb
+++ b/lib/liquid/tags/comment.rb
@@ -8,7 +8,7 @@ module Liquid
# @liquid_summary
# Prevents an expression from being rendered or output.
# @liquid_description
- # Any text inside `comment` tags won't be output, and any Liquid code won't be rendered.
+ # Any text inside `comment` tags won't be output, and any Liquid code will be parsed, but not executed.
# @liquid_syntax
# {% comment %}
# content
@@ -25,7 +25,64 @@ def unknown_tag(_tag, _markup, _tokens)
def blank?
true
end
- end
- Template.register_tag('comment', Comment)
+ 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
diff --git a/lib/liquid/tags/continue.rb b/lib/liquid/tags/continue.rb
index 2435899e3..48e235186 100644
--- a/lib/liquid/tags/continue.rb
+++ b/lib/liquid/tags/continue.rb
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category iteration
# @liquid_name continue
# @liquid_summary
- # Causes a [`for` loop](/api/liquid/tags#for) to skip to the next iteration.
+ # Causes a [`for` loop](/docs/api/liquid/tags/for) to skip to the next iteration.
# @liquid_syntax
# {% continue %}
class Continue < Tag
@@ -17,6 +17,4 @@ def render_to_output_buffer(context, output)
output
end
end
-
- Template.register_tag('continue', Continue)
end
diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb
index 3790d0641..b7d3069c8 100644
--- a/lib/liquid/tags/cycle.rb
+++ b/lib/liquid/tags/cycle.rb
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category iteration
# @liquid_name cycle
# @liquid_summary
- # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/api/liquid/tags#for).
+ # 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).
# @liquid_description
# The `cycle` tag must be used inside a `for` loop.
#
@@ -17,21 +17,17 @@ module Liquid
class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
+ UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
attr_reader :variables
def initialize(tag_name, markup, options)
super
- case markup
- when NamedSyntax
- @variables = variables_from_string(Regexp.last_match(2))
- @name = parse_expression(Regexp.last_match(1))
- when SimpleSyntax
- @variables = variables_from_string(markup)
- @name = @variables.to_s
- else
- raise SyntaxError, options[:locale].t("errors.syntax.cycle")
- end
+ parse_with_selected_parser(markup)
+ end
+
+ def named?
+ @is_named
end
def render_to_output_buffer(context, output)
@@ -59,19 +55,86 @@ def render_to_output_buffer(context, output)
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)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
- Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil
+ next unless Regexp.last_match(1)
+
+ var = parse_expression(Regexp.last_match(1))
+ maybe_dup_lookup(var)
end.compact
end
+ # For backwards compatibility, whenever a lookup is used in an unnamed cycle,
+ # we make it so that the @variables.to_s produces different strings for cycles
+ # called with the same arguments (since @variables.to_s is used as the cycle counter key)
+ # This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value.
+ # This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter.
+ # I was really scratching my head about this one, but migrating away from this would be more headache
+ # than it's worth. So we're keeping this quirk for now.
+ def maybe_dup_lookup(var)
+ var.is_a?(VariableLookup) ? var.dup : var
+ end
+
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
Array(@node.variables)
end
end
end
-
- Template.register_tag('cycle', Cycle)
end
diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb
index 1a2154d3b..70661716d 100644
--- a/lib/liquid/tags/decrement.rb
+++ b/lib/liquid/tags/decrement.rb
@@ -7,25 +7,45 @@ module Liquid
# @liquid_name decrement
# @liquid_summary
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
+ #
+ # > Caution:
+ # > Predefined Liquid objects can be overridden by variables with the same name.
+ # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_description
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
- # [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`](/api/liquid/tags#assign)
- # and [`capture`](/api/liquid/tags#capture). However, `decrement` and [`increment`](/api/liquid/tags#increment) share
+ # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
+ # and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share
# variables.
# @liquid_syntax
# {% decrement variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
class Decrement < Tag
+ include ParserSwitching
+
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
+ parse_with_selected_parser(markup)
+ end
+
+ def lax_parse(markup)
@variable_name = markup.strip
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)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
@@ -35,6 +55,4 @@ def render_to_output_buffer(context, output)
output
end
end
-
- Template.register_tag('decrement', Decrement)
end
diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb
new file mode 100644
index 000000000..499102d72
--- /dev/null
+++ b/lib/liquid/tags/doc.rb
@@ -0,0 +1,81 @@
+# 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
diff --git a/lib/liquid/tags/echo.rb b/lib/liquid/tags/echo.rb
index 9d3a60ca6..2c45c9cb6 100644
--- a/lib/liquid/tags/echo.rb
+++ b/lib/liquid/tags/echo.rb
@@ -9,10 +9,10 @@ module Liquid
# Outputs an expression.
# @liquid_description
# 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](/api/liquid/tags#liquid).
+ # bracket method, you can use the `echo` tag inside [`liquid` tags](/docs/api/liquid/tags/liquid).
#
# > Tip:
- # > You can use [filters](/api/liquid/filters) on expressions inside `echo` tags.
+ # > You can use [filters](/docs/api/liquid/filters) on expressions inside `echo` tags.
# @liquid_syntax
# {% liquid
# echo expression
@@ -36,6 +36,4 @@ def children
end
end
end
-
- Template.register_tag('echo', Echo)
end
diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb
index ff8c7fc94..598d7bee5 100644
--- a/lib/liquid/tags/for.rb
+++ b/lib/liquid/tags/for.rb
@@ -9,10 +9,10 @@ module Liquid
# Renders an expression for every item in an array.
# @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
- # [`paginate` tag](/api/liquid/tags#paginate) to split the items over multiple pages.
+ # [`paginate` tag](/docs/api/liquid/tags/paginate) to split the items over multiple pages.
#
# > Tip:
- # > Every `for` loop has an associated [`forloop` object](/api/liquid/objects#forloop) with information about the loop.
+ # > Every `for` loop has an associated [`forloop` object](/docs/api/liquid/objects/forloop) with information about the loop.
# @liquid_syntax
# {% for variable in array %}
# expression
@@ -20,8 +20,8 @@ module Liquid
# @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render for each iteration.
- # @liquid_optional_param limit [number] The number of iterations to perform.
- # @liquid_optional_param offset [number] The 1-based index to start iterating at.
+ # @liquid_optional_param limit: [number] The number of iterations to perform.
+ # @liquid_optional_param offset: [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
# @liquid_optional_param reversed [untyped] Iterate in reverse order.
class For < Block
@@ -88,28 +88,33 @@ def lax_parse(markup)
end
def strict_parse(markup)
- p = Parser.new(markup)
+ p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
collection_name = p.expression
- @collection_name = parse_expression(collection_name)
+ @collection_name = parse_expression(collection_name, safe: true)
@name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed')
- while p.look(:id) && p.look(:colon, 1)
+ while p.look(:comma) || p.look(:id)
+ p.consume?(:comma)
unless (attribute = p.id?('limit') || p.id?('offset'))
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end
- p.consume
- set_attribute(attribute, p.expression)
+ p.consume(:colon)
+ set_attribute(attribute, p.expression, safe: true)
end
p.consume(:end_of_string)
end
private
+ def strict2_parse(markup)
+ strict_parse(markup)
+ end
+
def collection_segment(context)
offsets = context.registers[:for] ||= {}
@@ -125,7 +130,6 @@ def collection_segment(context)
end
collection = context.evaluate(@collection_name)
- collection = collection.to_a if collection.is_a?(Range)
limit_value = context.evaluate(@limit)
to = if limit_value.nil?
@@ -134,7 +138,9 @@ def collection_segment(context)
Utils.to_integer(limit_value) + from
end
- segment = Utils.slice_collection(collection, from, to)
+ segment = Utils.slice_collection_for_iteration(
+ collection, from, to, context.resource_limits, use_range_to_a: true
+ )
segment.reverse! if @reversed
offsets[@name] = from + segment.length
@@ -173,17 +179,16 @@ def render_segment(context, output, segment)
output
end
- def set_attribute(key, expr)
+ def set_attribute(key, expr, safe: false)
case key
when 'offset'
@from = if expr == 'continue'
- Usage.increment('for_offset_continue')
:continue
else
- parse_expression(expr)
+ parse_expression(expr, safe: safe)
end
when 'limit'
- @limit = parse_expression(expr)
+ @limit = parse_expression(expr, safe: safe)
end
end
@@ -201,6 +206,4 @@ def children
end
end
end
-
- Template.register_tag('for', For)
end
diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb
index d15173f8d..c423c1e84 100644
--- a/lib/liquid/tags/if.rb
+++ b/lib/liquid/tags/if.rb
@@ -53,7 +53,7 @@ def unknown_tag(tag, markup, tokens)
def render_to_output_buffer(context, output)
@blocks.each do |block|
result = Liquid::Utils.to_liquid_value(
- block.evaluate(context)
+ block.evaluate(context),
)
if result
@@ -66,6 +66,10 @@ def render_to_output_buffer(context, output)
private
+ def strict2_parse(markup)
+ strict_parse(markup)
+ end
+
def push_block(tag, markup)
block = if tag == 'else'
ElseCondition.new
@@ -77,8 +81,8 @@ def push_block(tag, markup)
block.attach(new_body)
end
- def parse_expression(markup)
- Condition.parse_expression(parse_context, markup)
+ def parse_expression(markup, safe: false)
+ Condition.parse_expression(parse_context, markup, safe: safe)
end
def lax_parse(markup)
@@ -102,7 +106,7 @@ def lax_parse(markup)
end
def strict_parse(markup)
- p = Parser.new(markup)
+ p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
condition
@@ -111,7 +115,7 @@ def strict_parse(markup)
def parse_binary_comparisons(p)
condition = parse_comparison(p)
first_condition = condition
- while (op = (p.id?('and') || p.id?('or')))
+ while (op = p.id?('and') || p.id?('or'))
child_condition = parse_comparison(p)
condition.send(op, child_condition)
condition = child_condition
@@ -120,9 +124,9 @@ def parse_binary_comparisons(p)
end
def parse_comparison(p)
- a = parse_expression(p.expression)
+ a = parse_expression(p.expression, safe: true)
if (op = p.consume?(:comparison))
- b = parse_expression(p.expression)
+ b = parse_expression(p.expression, safe: true)
Condition.new(a, op, b)
else
Condition.new(a)
@@ -135,6 +139,4 @@ def children
end
end
end
-
- Template.register_tag('if', If)
end
diff --git a/lib/liquid/tags/ifchanged.rb b/lib/liquid/tags/ifchanged.rb
index dd3be532a..50397794d 100644
--- a/lib/liquid/tags/ifchanged.rb
+++ b/lib/liquid/tags/ifchanged.rb
@@ -14,6 +14,4 @@ def render_to_output_buffer(context, output)
output
end
end
-
- Template.register_tag('ifchanged', Ifchanged)
end
diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb
index f8fa05375..f0161c6e9 100644
--- a/lib/liquid/tags/include.rb
+++ b/lib/liquid/tags/include.rb
@@ -6,9 +6,9 @@ module Liquid
# @liquid_category theme
# @liquid_name include
# @liquid_summary
- # Renders a [snippet](/themes/architecture#snippets).
+ # Renders a [snippet](/themes/architecture/snippets).
# @liquid_description
- # Inside the snippet, you can access and alter variables that are [created](/api/liquid/tags#variable-tags) outside of the
+ # Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
# snippet.
# @liquid_syntax
# {% include 'filename' %}
@@ -16,35 +16,19 @@ module Liquid
# @liquid_deprecated
# 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`](/api/liquid/tags#render).
+ # The `include` tag has been replaced by [`render`](/docs/api/liquid/tags/render).
class Include < Tag
prepend Tag::Disableable
- SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
+ FOR = 'for'
+ SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
Syntax = SYNTAX
attr_reader :template_name_expr, :variable_name_expr, :attributes
def initialize(tag_name, markup, options)
super
-
- 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
+ parse_with_selected_parser(markup)
end
def parse(_tokens)
@@ -52,12 +36,12 @@ def parse(_tokens)
def render_to_output_buffer(context, output)
template_name = context.evaluate(@template_name_expr)
- raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name
+ raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name.is_a?(String)
partial = PartialCache.load(
template_name,
context: context,
- parse_context: parse_context
+ parse_context: parse_context,
)
context_variable_name = @alias_name || template_name.split('/').last
@@ -70,9 +54,11 @@ def render_to_output_buffer(context, output)
old_template_name = context.template_name
old_partial = context.partial
+
begin
- context.template_name = template_name
- context.partial = true
+ context.template_name = partial.name
+ context.partial = true
+
context.stack do
@attributes.each do |key, value|
context[key] = context.evaluate(value)
@@ -99,6 +85,57 @@ def render_to_output_buffer(context, output)
alias_method :parse_context, :options
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
def children
[
@@ -108,6 +145,4 @@ def children
end
end
end
-
- Template.register_tag('include', Include)
end
diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb
index 2965b3b12..ba4a54d2c 100644
--- a/lib/liquid/tags/increment.rb
+++ b/lib/liquid/tags/increment.rb
@@ -7,25 +7,45 @@ module Liquid
# @liquid_name increment
# @liquid_summary
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
+ #
+ # > Caution:
+ # > Predefined Liquid objects can be overridden by variables with the same name.
+ # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_description
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
- # [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`](/api/liquid/tags#assign)
- # and [`capture`](/api/liquid/tags#capture). However, `increment` and [`decrement`](/api/liquid/tags#decrement) share
+ # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
+ # and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share
# variables.
# @liquid_syntax
# {% increment variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
class Increment < Tag
+ include ParserSwitching
+
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
+ parse_with_selected_parser(markup)
+ end
+
+ def lax_parse(markup)
@variable_name = markup.strip
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)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
@@ -35,6 +55,4 @@ def render_to_output_buffer(context, output)
output
end
end
-
- Template.register_tag('increment', Increment)
end
diff --git a/lib/liquid/tags/inline_comment.rb b/lib/liquid/tags/inline_comment.rb
index e2cbc138d..3aa7c50f9 100644
--- a/lib/liquid/tags/inline_comment.rb
+++ b/lib/liquid/tags/inline_comment.rb
@@ -1,19 +1,6 @@
# frozen_string_literal: true
module Liquid
- # @liquid_public_docs
- # @liquid_type tag
- # @liquid_category syntax
- # @liquid_name inline_comment
- # @liquid_summary
- # Prevents an expression from being rendered or output.
- # @liquid_description
- # Any text inside an `inline_comment` tag won't be rendered or output.
- #
- # You can create multi-line inline comments. However, each line must begin with a `#`.
- # @liquid_syntax
- # {% # content %}
- # @liquid_syntax_keyword content The content of the comment.
class InlineComment < Tag
def initialize(tag_name, markup, options)
super
@@ -38,6 +25,4 @@ def blank?
true
end
end
-
- Template.register_tag('#', InlineComment)
end
diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb
index 6ffb6149d..2721c4a2d 100644
--- a/lib/liquid/tags/raw.rb
+++ b/lib/liquid/tags/raw.rb
@@ -14,7 +14,6 @@ module Liquid
# @liquid_syntax_keyword expression The expression to be output without being rendered.
class Raw < Block
Syntax = /\A\s*\z/
- FullTokenPossiblyInvalid = /\A(.*)#{TagStart}\s*(\w+)\s*(.*)?#{TagEnd}\z/om
def initialize(tag_name, markup, parse_context)
super
@@ -25,7 +24,8 @@ def initialize(tag_name, markup, parse_context)
def parse(tokens)
@body = +''
while (token = tokens.shift)
- if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
+ if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
+ parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return
end
@@ -56,6 +56,4 @@ def ensure_valid_markup(tag_name, markup, parse_context)
end
end
end
-
- Template.register_tag('raw', Raw)
end
diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb
index 30fd67d8d..6e1559cc2 100644
--- a/lib/liquid/tags/render.rb
+++ b/lib/liquid/tags/render.rb
@@ -6,21 +6,21 @@ module Liquid
# @liquid_category theme
# @liquid_name render
# @liquid_summary
- # Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
+ # Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# @liquid_description
- # Inside snippets and app blocks, you can't directly access variables that are [created](/api/liquid/tags#variable-tags) outside
- # of the snippet or app block. However, you can [specify variables as parameters](/api/liquid/tags#render-passing-variables-to-snippets)
+ # Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
+ # of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
# 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
# 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](/api/liquid/objects#product), and a snippet or app block inside a [section](/themes/architecture/sections)
- # can access the [`section` object](/api/liquid/objects#section).
+ # 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 [`section` object](/docs/api/liquid/objects/section).
#
# Outside a snippet or app block, you can't access variables created inside the snippet or app block.
#
# > Note:
- # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/api/liquid/tags#include)
+ # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/docs/api/liquid/tags/include)
# > inside the snippet.
# @liquid_syntax
# {% render 'filename' %}
@@ -35,22 +35,7 @@ class Render < Tag
def initialize(tag_name, markup, options)
super
-
- 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
+ parse_with_selected_parser(markup)
end
def for_loop?
@@ -69,14 +54,14 @@ def render_tag(context, output)
partial = PartialCache.load(
template_name,
context: context,
- parse_context: parse_context
+ parse_context: parse_context,
)
context_variable_name = @alias_name || template_name.split('/').last
render_partial_func = ->(var, forloop) {
inner_context = context.new_isolated_subcontext
- inner_context.template_name = template_name
+ inner_context.template_name = partial.name
inner_context.partial = true
inner_context['forloop'] = forloop if forloop
@@ -99,6 +84,55 @@ def render_tag(context, output)
output
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
def children
[
@@ -108,6 +142,4 @@ def children
end
end
end
-
- Template.register_tag('render', Render)
end
diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb
index 6ca528c71..7fd8530dd 100644
--- a/lib/liquid/tags/table_row.rb
+++ b/lib/liquid/tags/table_row.rb
@@ -11,7 +11,7 @@ module Liquid
# The `tablerow` tag must be wrapped in HTML `` tags.
#
# > Tip:
- # > Every `tablerow` loop has an associated [`tablerowloop` object](/api/liquid/objects#tablerowloop) with information about the loop.
+ # > Every `tablerow` loop has an associated [`tablerowloop` object](/docs/api/liquid/objects/tablerowloop) with information about the loop.
# @liquid_syntax
# {% tablerow variable in array %}
# expression
@@ -19,17 +19,54 @@ module Liquid
# @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render.
- # @liquid_optional_param cols [number] The number of columns that the table should have.
- # @liquid_optional_param limit [number] The number of iterations to perform.
- # @liquid_optional_param offset [number] The 1-based index to start iterating at.
+ # @liquid_optional_param cols: [number] The number of columns that the table should have.
+ # @liquid_optional_param limit: [number] The number of iterations to perform.
+ # @liquid_optional_param offset: [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
+ ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
attr_reader :variable_name, :collection_name, :attributes
def initialize(tag_name, markup, options)
super
+ parse_with_selected_parser(markup)
+ end
+
+ def 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
@variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2))
@@ -45,15 +82,16 @@ def initialize(tag_name, markup, options)
def render_to_output_buffer(context, output)
(collection = context.evaluate(@collection_name)) || (return '')
- from = @attributes.key?('offset') ? context.evaluate(@attributes['offset']).to_i : 0
- to = @attributes.key?('limit') ? from + context.evaluate(@attributes['limit']).to_i : nil
+ from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0
+ to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil
- collection = Utils.slice_collection(collection, from, to)
+ collection = Utils.slice_collection_for_iteration(collection, from, to, context.resource_limits, allow_endless: true)
length = collection.length
- cols = context.evaluate(@attributes['cols']).to_i
+ cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length
output << "\n"
+ context.resource_limits.increment_write_score(output)
context.stack do
tablerowloop = Liquid::TablerowloopDrop.new(length, cols)
context['tablerowloop'] = tablerowloop
@@ -64,9 +102,17 @@ def render_to_output_buffer(context, output)
output << "| "
super
output << ' | '
+ context.resource_limits.increment_write_score(output)
+
+ # 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
output << "
\n"
+ context.resource_limits.increment_write_score(output)
end
tablerowloop.send(:increment!)
@@ -74,6 +120,7 @@ def render_to_output_buffer(context, output)
end
output << "
\n"
+ context.resource_limits.increment_write_score(output)
output
end
@@ -82,7 +129,13 @@ def children
super + @node.attributes.values + [@node.collection_name]
end
end
- end
- Template.register_tag('tablerow', TableRow)
+ private
+
+ def to_integer(value)
+ value.to_i
+ rescue NoMethodError
+ raise Liquid::ArgumentError, "invalid integer"
+ end
+ end
end
diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb
index 30c65b0ac..a6faa6f93 100644
--- a/lib/liquid/tags/unless.rb
+++ b/lib/liquid/tags/unless.rb
@@ -11,7 +11,7 @@ module Liquid
# Renders an expression unless a specific condition is `true`.
# @liquid_description
# > Tip:
- # > Similar to the [`if` tag](/api/liquid/tags#if), you can use `elsif` to add more conditions to an `unless` tag.
+ # > Similar to the [`if` tag](/docs/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag.
# @liquid_syntax
# {% unless condition %}
# expression
@@ -23,7 +23,7 @@ def render_to_output_buffer(context, output)
# First condition is interpreted backwards ( if not )
first_block = @blocks.first
result = Liquid::Utils.to_liquid_value(
- first_block.evaluate(context)
+ first_block.evaluate(context),
)
unless result
@@ -33,7 +33,7 @@ def render_to_output_buffer(context, output)
# After the first condition unless works just like if
@blocks[1..-1].each do |block|
result = Liquid::Utils.to_liquid_value(
- block.evaluate(context)
+ block.evaluate(context),
)
if result
@@ -44,6 +44,4 @@ def render_to_output_buffer(context, output)
output
end
end
-
- Template.register_tag('unless', Unless)
end
diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb
index 39bde8338..2190e928d 100644
--- a/lib/liquid/template.rb
+++ b/lib/liquid/template.rb
@@ -2,7 +2,7 @@
module Liquid
# Templates are central to liquid.
- # Interpretating templates is a two step process. First you compile the
+ # Interpreting templates is a two step process. First you compile the
# source code you got. During compile time some extensive error checking is performed.
# your code should expect to get some SyntaxErrors.
#
@@ -15,98 +15,94 @@ module Liquid
# template.render('user_name' => 'bob')
#
class Template
- attr_accessor :root
+ attr_accessor :root, :name
attr_reader :resource_limits, :warnings
- class TagRegistry
- include Enumerable
+ attr_reader :profiler
- def initialize
- @tags = {}
- @cache = {}
+ class << self
+ # Sets how strict the parser should be.
+ # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
+ # :warn is the default and will give deprecation warnings when invalid syntax is used.
+ # :strict enforces correct syntax for most tags
+ # :strict2 enforces correct syntax for all tags
+ def error_mode=(mode)
+ Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
+ Environment.default.error_mode = mode
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 }
+ def error_mode
+ Environment.default.error_mode
end
- def []=(tag_name, klass)
- @tags[tag_name] = klass.name
- @cache[tag_name] = klass
+ def default_exception_renderer=(renderer)
+ Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=")
+ Environment.default.exception_renderer = renderer
end
- def delete(tag_name)
- @tags.delete(tag_name)
- @cache.delete(tag_name)
+ def default_exception_renderer
+ Environment.default.exception_renderer
end
- def each(&block)
- @tags.each(&block)
+ def file_system=(file_system)
+ Deprecations.warn("Template.file_system=", "Environment#file_system=")
+ Environment.default.file_system = file_system
end
- private
-
- def lookup_class(name)
- Object.const_get(name)
+ def file_system
+ Environment.default.file_system
end
- end
-
- attr_reader :profiler
-
- class << self
- # Sets how strict the parser should be.
- # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
- # :warn is the default and will give deprecation warnings when invalid syntax is used.
- # :strict will enforce correct syntax.
- attr_accessor :error_mode
- Template.error_mode = :lax
- attr_accessor :default_exception_renderer
- Template.default_exception_renderer = lambda do |exception|
- exception
+ def tags
+ Environment.default.tags
end
- attr_accessor :file_system
- Template.file_system = BlankFileSystem.new
-
- attr_accessor :tags
- Template.tags = TagRegistry.new
- private :tags=
-
def register_tag(name, klass)
- tags[name.to_s] = klass
+ Deprecations.warn("Template.register_tag", "Environment#register_tag")
+ Environment.default.register_tag(name, klass)
end
# Pass a module with filter methods which should be available
# to all liquid views. Good for registering the standard library
def register_filter(mod)
- StrainerFactory.add_global_filter(mod)
+ Deprecations.warn("Template.register_filter", "Environment#register_filter")
+ Environment.default.register_filter(mod)
+ end
+
+ private def default_resource_limits=(limits)
+ Deprecations.warn("Template.default_resource_limits=", "Environment#default_resource_limits=")
+ Environment.default.default_resource_limits = limits
end
- attr_accessor :default_resource_limits
- Template.default_resource_limits = {}
- private :default_resource_limits=
+ def default_resource_limits
+ Environment.default.default_resource_limits
+ end
# creates a new Template object from liquid source code
# To enable profiling, pass in profile: true as an option.
# See Liquid::Profiler for more information
def parse(source, options = {})
- new.parse(source, options)
+ environment = options[:environment] || Environment.default
+ new(environment: environment).parse(source, options)
end
end
- def initialize
+ def initialize(environment: Environment.default)
+ @environment = environment
@rethrow_errors = false
- @resource_limits = ResourceLimits.new(Template.default_resource_limits)
+ @resource_limits = ResourceLimits.new(environment.default_resource_limits)
end
# Parse source code.
# Returns self for easy chaining
def parse(source, 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)
@root = Document.parse(tokenizer, parse_context)
self
@@ -155,12 +151,14 @@ def render(*args)
c
when Liquid::Drop
- drop = args.shift
- drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
+ drop = args.shift
+ c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
+ drop.context = c if drop.respond_to?(:context=)
+ c
when Hash
- Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
+ Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when nil
- Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits)
+ Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
else
raise ArgumentError, "Expected Hash or Liquid::Context as parameter"
end
@@ -189,12 +187,22 @@ def render(*args)
@profiler = context.profiler = Liquid::Profiler.new
end
+ context.template_name ||= name
+
+ previous_error_mode = context.registers.static[:template_error_mode]
+ context.registers.static[:template_error_mode] = @error_mode
+
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
rescue Liquid::MemoryError => e
context.handle_error(e)
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
end
end
@@ -218,8 +226,15 @@ def configure_options(options)
@options = options
@profiling = profiling
@line_numbers = options[:line_numbers] || @profiling
- parse_context = options.is_a?(ParseContext) ? options : ParseContext.new(options)
- @warnings = parse_context.warnings
+ parse_context = if options.is_a?(ParseContext)
+ options
+ 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
end
diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb
index d3bda7b35..8b331d93c 100644
--- a/lib/liquid/tokenizer.rb
+++ b/lib/liquid/tokenizer.rb
@@ -1,18 +1,45 @@
# frozen_string_literal: true
+require "strscan"
+
module Liquid
class Tokenizer
attr_reader :line_number, :for_liquid_tag
- def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false)
- @source = source.to_s.to_str
- @line_number = line_number || (line_numbers ? 1 : nil)
+ TAG_END = /%\}/
+ TAG_OR_VARIABLE_START = /\{[\{\%]/
+ NEWLINE = /\n/
+
+ OPEN_CURLEY = "{".ord
+ CLOSE_CURLEY = "}".ord
+ PERCENTAGE = "%".ord
+
+ def initialize(
+ source:,
+ string_scanner:,
+ line_numbers: false,
+ line_number: nil,
+ for_liquid_tag: false
+ )
+ @line_number = line_number || (line_numbers ? 1 : nil)
@for_liquid_tag = for_liquid_tag
- @tokens = tokenize
+ @source = source.to_s.to_str
+ @offset = 0
+ @tokens = []
+
+ if @source
+ @ss = string_scanner
+ @ss.string = @source
+ tokenize
+ end
end
def shift
- (token = @tokens.shift) || return
+ token = @tokens[@offset]
+
+ return unless token
+
+ @offset += 1
if @line_number
@line_number += @for_liquid_tag ? 1 : token.count("\n")
@@ -24,16 +51,111 @@ def shift
private
def tokenize
- return [] if @source.empty?
+ if @for_liquid_tag
+ @tokens = @source.split("\n")
+ else
+ @tokens << shift_normal until @ss.eos?
+ end
+
+ @source = nil
+ @ss = nil
+ end
+
+ def shift_normal
+ token = next_token
+
+ return unless token
+
+ token
+ end
+
+ def next_token
+ # possible states: :text, :tag, :variable
+ byte_a = @ss.peek_byte
+
+ if byte_a == OPEN_CURLEY
+ @ss.scan_byte
+
+ byte_b = @ss.peek_byte
+
+ if byte_b == PERCENTAGE
+ @ss.scan_byte
+ return next_tag_token
+ elsif byte_b == OPEN_CURLEY
+ @ss.scan_byte
+ return next_variable_token
+ end
+
+ @ss.pos -= 1
+ end
+
+ next_text_token
+ end
+
+ def next_text_token
+ start = @ss.pos
+
+ unless @ss.skip_until(TAG_OR_VARIABLE_START)
+ token = @ss.rest
+ @ss.terminate
+ return token
+ end
+
+ pos = @ss.pos -= 2
+ @source.byteslice(start, pos - start)
+ rescue ::ArgumentError => e
+ if e.message == "invalid byte sequence in #{@ss.string.encoding}"
+ raise SyntaxError, "Invalid byte sequence in #{@ss.string.encoding}"
+ else
+ raise
+ end
+ end
+
+ def next_variable_token
+ start = @ss.pos - 2
- return @source.split("\n") if @for_liquid_tag
+ byte_a = byte_b = @ss.scan_byte
- tokens = @source.split(TemplateParser)
+ while byte_b
+ byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY
- # removes the rogue empty element at the beginning of the array
- tokens.shift if tokens[0]&.empty?
+ 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
- tokens
+ def next_tag_token_with_start(start)
+ @ss.skip_until(TAG_END)
+ @source.byteslice(start, @ss.pos - start)
end
end
end
diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb
index 38a406ef2..c735a4078 100644
--- a/lib/liquid/utils.rb
+++ b/lib/liquid/utils.rb
@@ -2,6 +2,9 @@
module Liquid
module Utils
+ DECIMAL_REGEX = /\A-?\d+\.\d+\z/
+ UNIX_TIMESTAMP_REGEX = /\A\d+\z/
+
def self.slice_collection(collection, from, to)
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
collection.load_slice(from, to)
@@ -10,6 +13,26 @@ def self.slice_collection(collection, from, to)
end
end
+ # This is intentionally separate from slice_collection, whose Array-returning
+ # behavior is used outside of the iteration tags.
+ def self.slice_collection_for_iteration(
+ collection, from, to, resource_limits, allow_endless: false, use_range_to_a: false
+ )
+ if integer_range?(collection)
+ RangeSlice.new(collection, from, to, resource_limits)
+ elsif collection.is_a?(Range)
+ if use_range_to_a && range_method_overridden?(collection, :to_a)
+ # For historically honored custom Range#to_a. Charge the resulting
+ # selection before buffering it, just as for a custom #each.
+ slice_collection_for_iteration_using_each(collection.to_a, from, to, resource_limits)
+ else
+ slice_range_using_each(collection, from, to, resource_limits, allow_endless: allow_endless)
+ end
+ else
+ slice_collection(collection, from, to)
+ end
+ end
+
def self.slice_collection_using_each(collection, from, to)
segments = []
index = 0
@@ -35,6 +58,55 @@ def self.slice_collection_using_each(collection, from, to)
segments
end
+ # Arithmetic slicing must not bypass a Range subclass's custom #each.
+ def self.integer_range?(collection)
+ collection.instance_of?(Range) && collection.begin.is_a?(Integer) && collection.end.is_a?(Integer)
+ end
+ private_class_method :integer_range?
+
+ # Preserve support for Ruby-supplied string ranges and custom Range#each.
+ # Their selected length cannot be inferred from integer bounds, but the tags
+ # need it before rendering for loop metadata, continuation offsets, and columns.
+ # Buffer the selection so we do not have to replay a potentially custom iterator.
+ def self.slice_range_using_each(collection, from, to, resource_limits, allow_endless:)
+ # TableRow historically accepted an endless subclass when its custom #each
+ # was finite, while For historically raised through Range#to_a.
+ if collection.end.nil? && !(allow_endless && (!to.nil? || range_method_overridden?(collection, :each)))
+ raise RangeError, "cannot convert endless range to an array"
+ end
+ if collection.begin.nil? && !range_method_overridden?(collection, :each)
+ raise TypeError, "can't iterate from NilClass"
+ end
+
+ slice_collection_for_iteration_using_each(collection, from, to, resource_limits)
+ end
+ private_class_method :slice_range_using_each
+
+ # Custom Range#each can make a nominally beginless range finite; standard
+ # beginless ranges were rejected before reaching this budgeted traversal.
+ def self.slice_collection_for_iteration_using_each(collection, from, to, resource_limits)
+ return [] if to && to <= from
+
+ segments = []
+ index = 0
+ collection.each do |item|
+ break if to && to <= index
+
+ # Charge preparation, including skipped offsets, before buffering; checking
+ # only while rendering the buffered values would leave this work unbudgeted.
+ resource_limits.increment_render_score(1)
+ segments << item if from <= index
+ index += 1
+ end
+ segments
+ end
+ private_class_method :slice_collection_for_iteration_using_each
+
+ def self.range_method_overridden?(collection, method_name)
+ collection.method(method_name).owner != Range.instance_method(method_name).owner
+ end
+ private_class_method :range_method_overridden?
+
def self.to_integer(num)
return num if num.is_a?(Integer)
num = num.to_s
@@ -52,7 +124,7 @@ def self.to_number(obj)
when Numeric
obj
when String
- /\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
+ DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
else
if obj.respond_to?(:to_number)
obj.to_number
@@ -66,14 +138,14 @@ def self.to_date(obj)
return obj if obj.respond_to?(:strftime)
if obj.is_a?(String)
- return nil if obj.empty?
+ return if obj.empty?
obj = obj.downcase
end
case obj
when 'now', 'today'
Time.now
- when /\A\d+\z/, Integer
+ when UNIX_TIMESTAMP_REGEX, Integer
Time.at(obj.to_i)
when String
Time.parse(obj)
@@ -89,5 +161,103 @@ def self.to_liquid_value(obj)
# Otherwise return the object itself
obj
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
diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb
index 1a4c07198..12b8d9f28 100644
--- a/lib/liquid/variable.rb
+++ b/lib/liquid/variable.rb
@@ -37,6 +37,10 @@ def raw
@markup
end
+ def ==(other)
+ self.class == other.class && name == other.name && filters == other.filters
+ end
+
def markup_context(markup)
"in \"{{#{markup}}}\""
end
@@ -54,26 +58,37 @@ def lax_parse(markup)
next unless f =~ /\w+/
filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten
- @filters << parse_filter_expressions(filtername, filterargs)
+ @filters << lax_parse_filter_expressions(filtername, filterargs)
end
end
end
def strict_parse(markup)
@filters = []
- p = Parser.new(markup)
+ p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
- @name = parse_context.parse_expression(p.expression)
+ @name = parse_context.safe_parse_expression(p)
while p.consume?(:pipe)
filtername = p.consume(:id)
- filterargs = p.consume?(:colon) ? parse_filterargs(p) : []
- @filters << parse_filter_expressions(filtername, filterargs)
+ filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
+ @filters << lax_parse_filter_expressions(filtername, filterargs)
end
p.consume(:end_of_string)
end
+ def strict2_parse(markup)
+ @filters = []
+ p = @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)
# first argument
filterargs = [p.argument]
@@ -95,15 +110,21 @@ def render(context)
def render_to_output_buffer(context, output)
obj = render(context)
+ render_obj_to_output(obj, output)
+ output
+ end
- if obj.is_a?(Array)
- output << obj.join
- elsif obj.nil?
+ def render_obj_to_output(obj, output)
+ case obj
+ when NilClass
+ # Do nothing
+ when Array
+ obj.each do |o|
+ render_obj_to_output(o, output)
+ end
else
- output << obj.to_s
+ output << Liquid::Utils.to_s(obj)
end
-
- output
end
def disabled?(_context)
@@ -116,7 +137,7 @@ def disabled_tags
private
- def parse_filter_expressions(filter_name, unparsed_args)
+ def lax_parse_filter_expressions(filter_name, unparsed_args)
filter_args = []
keyword_args = nil
unparsed_args.each do |a|
@@ -132,6 +153,46 @@ def parse_filter_expressions(filter_name, unparsed_args)
result
end
+ # Surprisingly, positional and keyword arguments can be mixed.
+ #
+ # filter = filtername [":" filterargs?]
+ # filterargs = argument ("," argument)*
+ # argument = (positional_argument | keyword_argument)
+ # positional_argument = expression
+ # keyword_argument = id ":" expression
+ def 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)
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
if filter_kwargs
diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb
index 1ade30ef9..4fba2a658 100644
--- a/lib/liquid/variable_lookup.rb
+++ b/lib/liquid/variable_lookup.rb
@@ -6,16 +6,20 @@ class VariableLookup
attr_reader :name, :lookups
- def self.parse(markup)
- new(markup)
+ def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
+ new(markup, string_scanner, cache)
end
- def initialize(markup)
+ def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
lookups = markup.scan(VariableParser)
name = lookups.shift
if name&.start_with?('[') && name&.end_with?(']')
- name = Expression.parse(name[1..-2])
+ name = Expression.parse(
+ name[1..-2],
+ string_scanner,
+ cache,
+ )
end
@name = name
@@ -25,7 +29,11 @@ def initialize(markup)
@lookups.each_index do |i|
lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']')
- lookups[i] = Expression.parse(lookup[1..-2])
+ lookups[i] = Expression.parse(
+ lookup[1..-2],
+ string_scanner,
+ cache,
+ )
elsif COMMAND_METHODS.include?(lookup)
@command_flags |= 1 << i
end
@@ -62,6 +70,11 @@ def evaluate(context)
elsif lookup_command?(i) && object.respond_to?(key)
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
# keywords either. The only thing we got left is to return nil or
# raise an exception if `strict_variables` option is set to true
diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb
index 7fdbe9b76..d05e11fb5 100644
--- a/lib/liquid/version.rb
+++ b/lib/liquid/version.rb
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
- VERSION = "5.4.0"
+ VERSION = "5.14.0"
end
diff --git a/liquid.gemspec b/liquid.gemspec
index 65176776e..1b9a91004 100644
--- a/liquid.gemspec
+++ b/liquid.gemspec
@@ -13,11 +13,11 @@ Gem::Specification.new do |s|
s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
s.authors = ["Tobias Lütke"]
s.email = ["tobi@leetsoft.com"]
- s.homepage = "http://www.liquidmarkup.org"
+ s.homepage = "https://shopify.github.io/liquid/"
s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup."
- s.required_ruby_version = ">= 2.7.0"
+ s.required_ruby_version = ">= 3.0.0"
s.required_rubygems_version = ">= 1.3.7"
s.metadata['allowed_push_host'] = 'https://rubygems.org'
@@ -28,6 +28,9 @@ Gem::Specification.new do |s|
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('minitest')
end
diff --git a/performance/benchmark.rb b/performance/benchmark.rb
index 8bf231340..b61e9057c 100644
--- a/performance/benchmark.rb
+++ b/performance/benchmark.rb
@@ -3,18 +3,23 @@
require 'benchmark/ips'
require_relative 'theme_runner'
-Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
+RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
+Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
+
profiler = ThemeRunner.new
Benchmark.ips do |x|
- x.time = 10
- x.warmup = 5
+ x.time = 20
+ x.warmup = 10
puts
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
puts
- x.report("parse:") { profiler.compile }
- x.report("render:") { profiler.render }
- x.report("parse & render:") { profiler.run }
+ phase = ENV["PHASE"] || "all"
+
+ x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize"
+ x.report("parse:") { profiler.compile } if phase == "all" || phase == "parse"
+ x.report("render:") { profiler.render } if phase == "all" || phase == "render"
+ x.report("parse & render:") { profiler.run } if phase == "all" || phase == "run"
end
diff --git a/performance/shopify/database.rb b/performance/shopify/database.rb
index 675f3b343..893c4e9d5 100644
--- a/performance/shopify/database.rb
+++ b/performance/shopify/database.rb
@@ -49,8 +49,3 @@ def self.tables
end
end
end
-
-if __FILE__ == $PROGRAM_NAME
- p(Database.tables['collections']['frontpage'].keys)
- # p Database.tables['blog']['articles']
-end
diff --git a/performance/shopify/liquid.rb b/performance/shopify/liquid.rb
index 40444c3c6..d30e67b04 100644
--- a/performance/shopify/liquid.rb
+++ b/performance/shopify/liquid.rb
@@ -11,11 +11,12 @@
require_relative 'tag_filter'
require_relative 'weight_filter'
-Liquid::Template.register_tag('paginate', Paginate)
-Liquid::Template.register_tag('form', CommentForm)
+default_environment = Liquid::Environment.default
+default_environment.register_tag('paginate', Paginate)
+default_environment.register_tag('form', CommentForm)
-Liquid::Template.register_filter(JsonFilter)
-Liquid::Template.register_filter(MoneyFilter)
-Liquid::Template.register_filter(WeightFilter)
-Liquid::Template.register_filter(ShopFilter)
-Liquid::Template.register_filter(TagFilter)
+default_environment.register_filter(JsonFilter)
+default_environment.register_filter(MoneyFilter)
+default_environment.register_filter(WeightFilter)
+default_environment.register_filter(ShopFilter)
+default_environment.register_filter(TagFilter)
diff --git a/performance/shopify/paginate.rb b/performance/shopify/paginate.rb
index fb5033571..3154d1838 100644
--- a/performance/shopify/paginate.rb
+++ b/performance/shopify/paginate.rb
@@ -45,8 +45,8 @@ def render_to_output_buffer(context, output)
pagination['items'] = collection_size
pagination['pages'] = page_count - 1
- pagination['previous'] = link('« Previous', current_page - 1) unless 1 >= current_page
- pagination['next'] = link('Next »', current_page + 1) unless page_count <= current_page + 1
+ pagination['previous'] = link('« Previous', current_page - 1) if 1 < current_page
+ pagination['next'] = link('Next »', current_page + 1) if page_count > current_page + 1
pagination['parts'] = []
hellip_break = false
diff --git a/performance/theme_runner.rb b/performance/theme_runner.rb
index 3dd5548e3..469503670 100644
--- a/performance/theme_runner.rb
+++ b/performance/theme_runner.rb
@@ -48,6 +48,19 @@ def compile
end
end
+ # `tokenize` will just test the tokenizen portion of liquid without any templates
+ def tokenize
+ ss = StringScanner.new("")
+ @tests.each do |test_hash|
+ tokenizer = Liquid::Tokenizer.new(
+ source: test_hash[:liquid],
+ string_scanner: ss,
+ line_numbers: true,
+ )
+ while tokenizer.shift; end
+ end
+ end
+
# `run` is called to benchmark rendering and compiling at the same time
def run
each_test do |liquid, layout, assigns, page_template, template_name|
diff --git a/performance/unit/expression_benchmark.rb b/performance/unit/expression_benchmark.rb
new file mode 100644
index 000000000..8cd552595
--- /dev/null
+++ b/performance/unit/expression_benchmark.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require "benchmark/ips"
+
+# benchmark liquid lexing
+
+require 'liquid'
+
+RubyVM::YJIT.enable
+
+STRING_MARKUPS = [
+ "\"foo\"",
+ "\"fooooooooooo\"",
+ "\"foooooooooooooooooooooooooooooo\"",
+ "'foo'",
+ "'fooooooooooo'",
+ "'foooooooooooooooooooooooooooooo'",
+]
+
+VARIABLE_MARKUPS = [
+ "article",
+ "article.title",
+ "article.title.size",
+ "very_long_variable_name_2024_11_05",
+ "very_long_variable_name_2024_11_05.size",
+]
+
+NUMBER_MARKUPS = [
+ "0",
+ "35",
+ "1241891024912849",
+ "3.5",
+ "3.51214128409128",
+ "12381902839.123819283910283",
+ "123.456.789",
+ "-123",
+ "-12.33",
+ "-405.231",
+ "-0",
+ "0",
+ "0.0",
+ "0.0000000000000000000000",
+ "0.00000000001",
+]
+
+RANGE_MARKUPS = [
+ "(1..30)",
+ "(1...30)",
+ "(1..30..5)",
+ "(1.0...30.0)",
+ "(1.........30)",
+ "(1..foo)",
+ "(foo..30)",
+ "(foo..bar)",
+ "(foo...bar...100)",
+ "(foo...bar...100.0)",
+]
+
+LITERAL_MARKUPS = [
+ nil,
+ 'nil',
+ 'null',
+ '',
+ 'true',
+ 'false',
+ 'blank',
+ 'empty',
+]
+
+MARKUPS = {
+ "string" => STRING_MARKUPS,
+ "literal" => LITERAL_MARKUPS,
+ "variable" => VARIABLE_MARKUPS,
+ "number" => NUMBER_MARKUPS,
+ "range" => RANGE_MARKUPS,
+}
+
+Benchmark.ips do |x|
+ x.config(time: 5, warmup: 5)
+
+ MARKUPS.each do |type, markups|
+ x.report("Liquid::Expression#parse: #{type}") do
+ markups.each do |markup|
+ Liquid::Expression.parse(markup)
+ end
+ end
+ end
+
+ x.report("Liquid::Expression#parse: all") do
+ MARKUPS.values.flatten.each do |markup|
+ Liquid::Expression.parse(markup)
+ end
+ end
+end
diff --git a/performance/unit/lexer_benchmark.rb b/performance/unit/lexer_benchmark.rb
new file mode 100644
index 000000000..fda6ce1f6
--- /dev/null
+++ b/performance/unit/lexer_benchmark.rb
@@ -0,0 +1,43 @@
+# 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
diff --git a/spec/ruby_liquid.rb b/spec/ruby_liquid.rb
new file mode 100644
index 000000000..c78049776
--- /dev/null
+++ b/spec/ruby_liquid.rb
@@ -0,0 +1,49 @@
+# 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
diff --git a/spec/ruby_liquid_lax.rb b/spec/ruby_liquid_lax.rb
new file mode 100644
index 000000000..42cf9a9a3
--- /dev/null
+++ b/spec/ruby_liquid_lax.rb
@@ -0,0 +1,46 @@
+# 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
diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb
new file mode 100644
index 000000000..25e02b3f3
--- /dev/null
+++ b/spec/ruby_liquid_with_active_support.rb
@@ -0,0 +1,49 @@
+# 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
diff --git a/spec/ruby_liquid_yjit.rb b/spec/ruby_liquid_yjit.rb
new file mode 100644
index 000000000..f43f5ac70
--- /dev/null
+++ b/spec/ruby_liquid_yjit.rb
@@ -0,0 +1,53 @@
+# 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
diff --git a/spec/support/liquid_spec_adapter_helper.rb b/spec/support/liquid_spec_adapter_helper.rb
new file mode 100644
index 000000000..a572bbde9
--- /dev/null
+++ b/spec/support/liquid_spec_adapter_helper.rb
@@ -0,0 +1,24 @@
+# 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
diff --git a/test/integration/assign_test.rb b/test/integration/assign_test.rb
index ba795fbbf..2388b357d 100644
--- a/test/integration/assign_test.rb
+++ b/test/integration/assign_test.rb
@@ -14,19 +14,25 @@ def test_assign_with_hyphen_in_variable_name
end
def test_assigned_variable
- assert_template_result('.foo.',
+ assert_template_result(
+ '.foo.',
'{% assign foo = values %}.{{ foo[0] }}.',
- { 'values' => %w(foo bar baz) })
+ { 'values' => %w(foo bar baz) },
+ )
- assert_template_result('.bar.',
+ assert_template_result(
+ '.bar.',
'{% assign foo = values %}.{{ foo[1] }}.',
- { 'values' => %w(foo bar baz) })
+ { 'values' => %w(foo bar baz) },
+ )
end
def test_assign_with_filter
- assert_template_result('.bar.',
+ assert_template_result(
+ '.bar.',
'{% assign foo = values | split: "," %}.{{ foo[1] }}.',
- { 'values' => "foo,bar,baz" })
+ { 'values' => "foo,bar,baz" },
+ )
end
def test_assign_syntax_error
@@ -34,8 +40,11 @@ def test_assign_syntax_error
end
def test_assign_uses_error_mode
- assert_match_syntax_error("Expected dotdot but found pipe in ",
- "{% assign foo = ('X' | downcase) %}", error_mode: :strict)
+ assert_match_syntax_error(
+ "Expected dotdot but found pipe in ",
+ "{% assign foo = ('X' | downcase) %}",
+ error_mode: :strict,
+ )
assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax)
end
@@ -88,6 +97,46 @@ def test_assign_score_of_hash
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
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
class ObjectWrapperDrop < Liquid::Drop
diff --git a/test/integration/blank_body_error_handling_test.rb b/test/integration/blank_body_error_handling_test.rb
new file mode 100644
index 000000000..c395f867e
--- /dev/null
+++ b/test/integration/blank_body_error_handling_test.rb
@@ -0,0 +1,85 @@
+# 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
diff --git a/test/integration/blank_test.rb b/test/integration/blank_test.rb
index b7ca205db..9705d3b55 100644
--- a/test/integration/blank_test.rb
+++ b/test/integration/blank_test.rb
@@ -27,7 +27,7 @@ def wrap(body)
def test_new_tags_are_not_blank_by_default
with_custom_tag('foobar', FoobarTag) do
- assert_template_result(" " * N, wrap_in_for("{% foobar %}"))
+ assert_equal(" " * N, Liquid::Template.parse(wrap_in_for("{% foobar %}")).render!)
end
end
@@ -57,9 +57,11 @@ def test_captures_are_blank
def test_nested_blocks_are_blank_but_only_if_all_children_are
assert_template_result("", wrap(wrap(" ")))
- assert_template_result("\n but this is not " * (N + 1),
+ assert_template_result(
+ "\n but this is not " * (N + 1),
wrap('{% if true %} {% comment %} this is blank {% endcomment %} {% endif %}
- {% if true %} but this is not {% endif %}'))
+ {% if true %} but this is not {% endif %}'),
+ )
end
def test_assigns_are_blank
@@ -89,12 +91,21 @@ def test_raw_is_not_blank
end
def test_include_is_blank
- assert_template_result("foobar" * (N + 1), wrap("{% include 'foobar' %}"),
- partials: { 'foobar' => 'foobar' })
- assert_template_result(" foobar " * (N + 1), wrap("{% include ' foobar ' %}"),
- partials: { ' foobar ' => ' foobar ' })
- assert_template_result(" " * (N + 1), wrap(" {% include ' ' %} "),
- partials: { ' ' => ' ' })
+ assert_template_result(
+ "foobar" * (N + 1),
+ wrap("{% include 'foobar' %}"),
+ partials: { 'foobar' => 'foobar' },
+ )
+ assert_template_result(
+ " foobar " * (N + 1),
+ wrap("{% include ' foobar ' %}"),
+ partials: { ' foobar ' => ' foobar ' },
+ )
+ assert_template_result(
+ " " * (N + 1),
+ wrap(" {% include ' ' %} "),
+ partials: { ' ' => ' ' },
+ )
end
def test_case_is_blank
diff --git a/test/integration/capture_test.rb b/test/integration/capture_test.rb
index 7399393ac..2f844a7c0 100644
--- a/test/integration/capture_test.rb
+++ b/test/integration/capture_test.rb
@@ -6,7 +6,11 @@ class CaptureTest < Minitest::Test
include Liquid
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
def test_capture_with_hyphen_in_variable_name
@@ -49,4 +53,35 @@ def test_increment_assign_score_by_bytes_not_characters
t.render!
assert_equal(9, t.resource_limits.assign_score)
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
diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb
index 698aaa427..8fa83ddb5 100644
--- a/test/integration/context_test.rb
+++ b/test/integration/context_test.rb
@@ -36,6 +36,24 @@ def to_liquid
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
attr_accessor :category, :context
@@ -121,14 +139,23 @@ def test_scoping
end
def test_length_query
- assert_template_result("true", "{% if numbers.size == 4 %}true{% endif %}",
- { "numbers" => [1, 2, 3, 4] })
+ assert_template_result(
+ "true",
+ "{% if numbers.size == 4 %}true{% endif %}",
+ { "numbers" => [1, 2, 3, 4] },
+ )
- assert_template_result("true", "{% if numbers.size == 4 %}true{% endif %}",
- { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } })
+ assert_template_result(
+ "true",
+ "{% if numbers.size == 4 %}true{% endif %}",
+ { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } },
+ )
- assert_template_result("true", "{% if numbers.size == 1000 %}true{% endif %}",
- { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } })
+ assert_template_result(
+ "true",
+ "{% if numbers.size == 1000 %}true{% endif %}",
+ { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } },
+ )
end
def test_hyphenated_variable
@@ -228,12 +255,14 @@ def test_recoursive_array_notation
end
def test_hash_to_array_transition
- assigns = { 'colors' => {
- 'Blue' => ['003366', '336699', '6699CC', '99CCFF'],
- 'Green' => ['003300', '336633', '669966', '99CC99'],
- 'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'],
- 'Red' => ['660000', '993333', 'CC6666', 'FF9999'],
- } }
+ assigns = {
+ 'colors' => {
+ 'Blue' => ['003366', '336699', '6699CC', '99CCFF'],
+ 'Green' => ['003300', '336633', '669966', '99CC99'],
+ 'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'],
+ 'Red' => ['660000', '993333', 'CC6666', 'FF9999'],
+ },
+ }
assert_template_result("003366", "{{ colors.Blue[0] }}", assigns)
assert_template_result("FF9999", "{{ colors.Red[3] }}", assigns)
@@ -262,13 +291,13 @@ def test_access_hashes_with_hash_notation
assigns = { 'product' => { 'variants' => [{ 'title' => 'draft151cm' }, { 'title' => 'element151cm' }] } }
assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns)
assert_template_result("element151cm", '{{ product["variants"][1]["title"] }}', assigns)
- assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns)
+ assert_template_result("draft151cm", '{{ product["variants"].first["title"] }}', assigns)
assert_template_result("element151cm", '{{ product["variants"].last["title"] }}', assigns)
end
def test_access_variable_with_hash_notation
- assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
- assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
+ assert_template_result('baz', '{{ foo }}', { "foo" => "baz" })
+ assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
end
def test_access_hashes_with_hash_access_variables
@@ -410,10 +439,12 @@ def test_lambda_is_called_once
def test_nested_lambda_is_called_once
@global = 0
- @context['callcount'] = { "lambda" => proc {
- @global += 1
- @global.to_s
- } }
+ @context['callcount'] = {
+ "lambda" => proc {
+ @global += 1
+ @global.to_s
+ },
+ }
assert_equal('1', @context['callcount.lambda'])
assert_equal('1', @context['callcount.lambda'])
@@ -423,10 +454,11 @@ def test_nested_lambda_is_called_once
def test_lambda_in_array_is_called_once
@global = 0
- @context['callcount'] = [1, 2, proc {
- @global += 1
- @global.to_s
- }, 4, 5]
+ p = proc {
+ @global += 1
+ @global.to_s
+ }
+ @context['callcount'] = [1, 2, p, 4, 5]
assert_equal('1', @context['callcount[2]'])
assert_equal('1', @context['callcount[2]'])
@@ -473,7 +505,7 @@ def test_apply_global_filter
def test_static_environments_are_read_with_lower_priority_than_environments
context = Context.build(
static_environments: { 'shadowed' => 'static', 'unshadowed' => 'static' },
- environments: { 'shadowed' => 'dynamic' }
+ environments: { 'shadowed' => 'dynamic' },
)
assert_equal('dynamic', context['shadowed'])
@@ -600,13 +632,28 @@ def notice(output)
end
def test_has_key_will_not_add_an_error_for_missing_keys
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
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
registers = {
my_register: :my_value,
@@ -621,6 +668,40 @@ def test_context_always_uses_static_registers
assert_equal(:my_value, c.registers[:my_register])
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
def assert_no_object_allocations
diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb
index b7ee624de..e2e3ed111 100644
--- a/test/integration/error_handling_test.rb
+++ b/test/integration/error_handling_test.rb
@@ -67,7 +67,7 @@ def test_missing_endtag_parse_time_error
end
def test_unrecognized_operator
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
@@ -105,7 +105,8 @@ def test_with_line_numbers_adds_numbers_to_parser_errors_with_whitespace_trim
end
def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
- template = Liquid::Template.parse('
+ template = Liquid::Template.parse(
+ '
foobar
{% if 1 =! 2 %}ok{% endif %}
@@ -113,15 +114,19 @@ def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
bla
',
error_mode: :warn,
- line_numbers: true)
+ line_numbers: true,
+ )
- assert_equal(['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'],
- template.warnings.map(&:message))
+ assert_equal(
+ ['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'],
+ template.warnings.map(&:message),
+ )
end
def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors
err = assert_raises(SyntaxError) do
- Liquid::Template.parse('
+ Liquid::Template.parse(
+ '
foobar
{% if 1 =! 2 %}ok{% endif %}
@@ -129,7 +134,8 @@ def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors
bla
',
error_mode: :strict,
- line_numbers: true)
+ line_numbers: true,
+ )
end
assert_equal('Liquid syntax error (line 4): Unexpected character = in "1 =! 2"', err.message)
@@ -197,20 +203,34 @@ def test_default_exception_renderer_with_internal_error
end
def test_setting_default_exception_renderer
- old_exception_renderer = Liquid::Template.default_exception_renderer
exceptions = []
- Liquid::Template.default_exception_renderer = ->(e) {
+ default_exception_renderer = ->(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)
assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
- ensure
- Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer
+ end
+
+ 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
def test_exception_renderer_exposing_non_liquid_error
@@ -236,25 +256,98 @@ def read_template_file(_template_path)
end
def test_included_template_name_with_line_numbers
- old_file_system = Liquid::Template.file_system
+ environment = Liquid::Environment.build(file_system: TestFileSystem.new)
+ 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("product", template.errors.first.template_name)
end
def test_bug_compatible_silencing_of_errors_in_blank_nodes
- 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)
+ with_error_modes(:lax, :strict) do
+ 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)
+
+ output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
+ assert_equal("0", output)
+ end
+ 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
- output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
- assert_equal("0", output)
+ 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
diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb
index 47734484d..ae84fa36d 100644
--- a/test/integration/expression_test.rb
+++ b/test/integration/expression_test.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'test_helper'
+require 'lru_redux'
class ExpressionTest < Minitest::Test
def test_keyword_literals
@@ -13,6 +14,7 @@ def test_string
assert_template_result("double quoted", '{{"double quoted"}}')
assert_template_result("spaced", "{{ 'spaced' }}")
assert_template_result("spaced2", "{{ 'spaced2' }}")
+ assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
end
def test_int
@@ -22,8 +24,22 @@ def test_int
end
def test_float
+ assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}")
+
+ with_error_modes(:lax) do
+ assert_expression_result(0.0, "0.....5")
+ assert_expression_result(0.0, "-0..1")
+ end
+
assert_expression_result(1.5, "1.5")
+
+ # this is a unfortunate quirky behavior of Liquid
+ result = Expression.parse(".5")
+ assert_kind_of(Liquid::VariableLookup, result)
+
+ result = Expression.parse("-.5")
+ assert_kind_of(Liquid::VariableLookup, result)
end
def test_range
@@ -32,14 +48,139 @@ def test_range
assert_match_syntax_error(
"Liquid syntax error (line 1): Invalid expression type 'false' in range expression",
- "{{ (false..true) }}"
+ "{{ (false..true) }}",
)
assert_match_syntax_error(
"Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression",
- "{{ ((1..2)..3) }}"
+ "{{ ((1..2)..3) }}",
+ )
+ 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
def assert_expression_result(expect, markup, **assigns)
diff --git a/test/integration/filter_kwarg_test.rb b/test/integration/filter_kwarg_test.rb
index 2bd3cbdfc..b41f6d679 100644
--- a/test/integration/filter_kwarg_test.rb
+++ b/test/integration/filter_kwarg_test.rb
@@ -17,7 +17,7 @@ def test_can_parse_data_kwargs
with_global_filter(KwargFilter) do
assert_equal(
"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
diff --git a/test/integration/filter_test.rb b/test/integration/filter_test.rb
index 80d652d07..133b57b3b 100644
--- a/test/integration/filter_test.rb
+++ b/test/integration/filter_test.rb
@@ -68,22 +68,34 @@ def test_join
def test_sort
assert_template_result("1 2 3 4", "{{numbers | sort | join}}", { "numbers" => [2, 1, 4, 3] })
- assert_template_result("alphabetic as expected", "{{words | sort | join}}",
- { "words" => ['expected', 'as', 'alphabetic'] })
+ assert_template_result(
+ "alphabetic as expected",
+ "{{words | sort | join}}",
+ { "words" => ['expected', 'as', 'alphabetic'] },
+ )
assert_template_result("3", "{{value | sort}}", { "value" => 3 })
assert_template_result('are flower', "{{arrays | sort | join}}", { 'arrays' => ['flower', 'are'] })
- assert_template_result("Expected case sensitive", "{{case_sensitive | sort | join}}",
- { "case_sensitive" => ["sensitive", "Expected", "case"] })
+ assert_template_result(
+ "Expected case sensitive",
+ "{{case_sensitive | sort | join}}",
+ { "case_sensitive" => ["sensitive", "Expected", "case"] },
+ )
end
def test_sort_natural
# Test strings
- assert_template_result("Assert case Insensitive", "{{words | sort_natural | join}}",
- { "words" => ["case", "Assert", "Insensitive"] })
+ assert_template_result(
+ "Assert case Insensitive",
+ "{{words | sort_natural | join}}",
+ { "words" => ["case", "Assert", "Insensitive"] },
+ )
# Test hashes
- assert_template_result("A b C", "{{hashes | sort_natural: 'a' | map: 'a' | join}}",
- { "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] })
+ assert_template_result(
+ "A b C",
+ "{{hashes | sort_natural: 'a' | map: 'a' | join}}",
+ { "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] },
+ )
# Test objects
@context['objects'] = [TestObject.new('A'), TestObject.new('b'), TestObject.new('C')]
@@ -92,12 +104,18 @@ def test_sort_natural
def test_compact
# Test strings
- assert_template_result("a b c", "{{words | compact | join}}",
- { "words" => ['a', nil, 'b', nil, 'c'] })
+ assert_template_result(
+ "a b c",
+ "{{words | compact | join}}",
+ { "words" => ['a', nil, 'b', nil, 'c'] },
+ )
# Test hashes
- assert_template_result("A C", "{{hashes | compact: 'a' | map: 'a' | join}}",
- { "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] })
+ assert_template_result(
+ "A C",
+ "{{hashes | compact: 'a' | map: 'a' | join}}",
+ { "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] },
+ )
# Test objects
@context['objects'] = [TestObject.new('A'), TestObject.new(nil), TestObject.new('C')]
@@ -109,8 +127,11 @@ def test_strip_html
end
def test_strip_html_ignore_comments_with_html
- assert_template_result("bla blub", "{{ var | strip_html }}",
- { "var" => "bla blub" })
+ assert_template_result(
+ "bla blub",
+ "{{ var | strip_html }}",
+ { "var" => "bla blub" },
+ )
end
def test_capitalize
diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb
new file mode 100644
index 000000000..0c1b33e48
--- /dev/null
+++ b/test/integration/hash_rendering_test.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class HashRenderingTest < Minitest::Test
+ def test_render_empty_hash
+ assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} })
+ end
+
+ def test_render_hash_with_string_keys_and_values
+ assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } })
+ end
+
+ def test_render_hash_with_symbol_keys_and_integer_values
+ assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } })
+ end
+
+ def test_render_nested_hash
+ assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } })
+ end
+
+ def test_render_hash_with_array_values
+ assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } })
+ end
+
+ def test_render_recursive_hash
+ recursive_hash = { "self" => {} }
+ recursive_hash["self"]["self"] = recursive_hash
+ assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash })
+ end
+
+ def test_hash_with_downcase_filter
+ assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_upcase_filter
+ assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_strip_filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_escape_filter
+ assert_template_result("{"Key"=>"Value", "AnotherKey"=>"AnotherValue"}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_url_encode_filter
+ assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_strip_html_filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_truncate__20_filter
+ assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_replace___key____replaced_key__filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_append____appended_text__filter
+ assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_hash_with_prepend___prepended_text___filter
+ assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
+ end
+
+ def test_render_hash_with_array_values_empty
+ assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } })
+ end
+
+ def test_render_hash_with_array_values_hash
+ assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } })
+ end
+
+ def test_join_filter_with_hash
+ array = [{ "key1" => "value1" }, { "key2" => "value2" }]
+ glue = { "lol" => "wut" }
+ assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue })
+ end
+
+ def test_render_hash_with_hash_key
+ assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } })
+ end
+
+ def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
+ 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
diff --git a/test/integration/parsing_quirks_test.rb b/test/integration/parsing_quirks_test.rb
index 744936c97..b82ce86c5 100644
--- a/test/integration/parsing_quirks_test.rb
+++ b/test/integration/parsing_quirks_test.rb
@@ -31,18 +31,18 @@ def test_raise_on_label_and_no_close_bracets_percent
def test_error_on_empty_filter
assert(Template.parse("{{test}}"))
- with_error_mode(:lax) do
+ with_error_modes(:lax) do
assert(Template.parse("{{|test}}"))
end
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end
end
def test_meaningless_parens_error
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
@@ -51,7 +51,7 @@ def test_meaningless_parens_error
end
def test_unexpected_characters_syntax_error
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
@@ -70,7 +70,7 @@ def test_no_error_on_lax_empty_filter
end
def test_meaningless_parens_lax
- with_error_mode(:lax) do
+ with_error_modes(:lax) do
assigns = { 'b' => 'bar', 'c' => 'baz' }
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
@@ -78,7 +78,7 @@ def test_meaningless_parens_lax
end
def test_unexpected_characters_silently_eat_logic_lax
- with_error_mode(:lax) do
+ with_error_modes(:lax) do
markup = "true && false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
markup = "false || true"
@@ -93,7 +93,7 @@ def test_raise_on_invalid_tag_delimiter
end
def test_unanchored_filter_arguments
- with_error_mode(:lax) do
+ with_error_modes(:lax) do
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
assert_template_result('x', "{{ 'X' | downcase) }}")
@@ -106,14 +106,14 @@ def test_unanchored_filter_arguments
end
def test_invalid_variables_work
- with_error_mode(:lax) do
+ with_error_modes(:lax) do
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
end
end
def test_extra_dots_in_ranges
- with_error_mode(:lax) do
+ with_error_modes(:lax) do
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end
end
@@ -131,4 +131,24 @@ def test_lookup_on_var_with_literal_name
def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true })
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
diff --git a/test/integration/profiler_test.rb b/test/integration/profiler_test.rb
index 1a610c56a..70a22fc06 100644
--- a/test/integration/profiler_test.rb
+++ b/test/integration/profiler_test.rb
@@ -33,7 +33,7 @@ def read_template_file(template_path)
end
def setup
- Liquid::Template.file_system = ProfilingFileSystem.new
+ Liquid::Environment.default.file_system = ProfilingFileSystem.new
end
def test_template_allows_flagging_profiling
diff --git a/test/integration/security_test.rb b/test/integration/security_test.rb
index 75f092965..09a21521f 100644
--- a/test/integration/security_test.rb
+++ b/test/integration/security_test.rb
@@ -44,33 +44,43 @@ def test_no_instance_eval_later_in_chain
end
def test_does_not_permanently_add_filters_to_symbol_table
- current_symbols = Symbol.all_symbols
+ assert_no_new_symbols do
+ # 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
+ end
+ end
- # 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
+ def test_does_not_add_drop_methods_to_symbol_table
+ assigns = { 'drop' => Drop.new }
+ method_names = Array.new(3) { |index| "untrusted_drop_method_#{object_id}_#{index}" }
- GC.start
+ method_names.each do |method_name|
+ assert_equal("", Template.parse("{{ drop.#{method_name} }}").render!(assigns))
+ end
- assert_equal([], (Symbol.all_symbols - current_symbols))
+ # JITs can intern internal metadata; untrusted Drop method names must not be interned.
+ assert_equal([], Symbol.all_symbols.map(&:to_s) & method_names)
end
- def test_does_not_add_drop_methods_to_symbol_table
- current_symbols = Symbol.all_symbols
+ 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
- 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))
+ # Ensure no new symbols for further runs, i.e. the code does not leak symbols
+ current_symbols = Symbol.all_symbols
+ yield
+ assert_equal([], Symbol.all_symbols - current_symbols)
end
def test_max_depth_nested_blocks_does_not_raise_exception
diff --git a/test/integration/self_drop_context_test.rb b/test/integration/self_drop_context_test.rb
new file mode 100644
index 000000000..f57fdc393
--- /dev/null
+++ b/test/integration/self_drop_context_test.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class SelfDropContextTest < Minitest::Test
+ include Liquid
+
+ def test_self_drop_passed_as_render_param_preserves_original_scope
+ source = <<~LIQUID
+ {%- assign var = 42 -%}
+ {%- assign s = self -%}
+ {%- render "snippet1", other_self: s -%}
+ LIQUID
+
+ partials = {
+ 'snippet1' => <<~LIQUID,
+ {%- assign var = 43 -%}
+ {{- other_self.var }}|{{ self.var -}}
+ LIQUID
+ }
+
+ assert_template_result('42|43', source, partials: partials)
+ end
+
+ def test_self_drop_in_render_without_passing_resolves_inner_scope
+ source = <<~LIQUID
+ {%- assign var = 42 -%}
+ {%- render "snippet1" -%}
+ LIQUID
+
+ partials = {
+ 'snippet1' => <<~LIQUID,
+ {%- assign var = 99 -%}
+ {{- self.var -}}
+ LIQUID
+ }
+
+ assert_template_result('99', source, partials: partials)
+ end
+
+ def test_self_drop_passed_to_nested_renders_preserves_each_level
+ source = <<~LIQUID
+ {%- assign a = 1 -%}
+ {%- assign s1 = self -%}
+ {%- render "snippet1", outer: s1 -%}
+ LIQUID
+
+ partials = {
+ 'snippet1' => <<~LIQUID,
+ {%- assign a = 2 -%}
+ {%- assign s2 = self -%}
+ {%- render "snippet2", outer: outer, middle: s2 -%}
+ LIQUID
+ 'snippet2' => <<~LIQUID,
+ {%- assign a = 3 -%}
+ {{- outer.a }}|{{ middle.a }}|{{ self.a -}}
+ LIQUID
+ }
+
+ assert_template_result('1|2|3', source, partials: partials)
+ end
+
+ def test_self_drop_reflects_variables_assigned_after_creation
+ source = <<~LIQUID
+ {%- assign s = self -%}
+ {%- assign x = 42 %}{{ s.x -}}
+ LIQUID
+
+ assert_template_result('42', source)
+ end
+
+ def test_self_drop_context_setter_is_undefined
+ context = Context.new
+ drop = SelfDrop.new(context)
+ refute(drop.respond_to?(:context=))
+
+ assert_template_result('42', '{{ self.x }}', { 'x' => 42 })
+ end
+
+ def test_self_drop_repeated_lookups_compare_equal_for_same_context
+ context = Context.new
+ drop = context.find_variable("self")
+ cached_drop = context.find_variable("self")
+
+ assert_same(drop, cached_drop)
+ assert_equal(drop.object_id, cached_drop.object_id)
+ assert_equal(drop, cached_drop)
+ end
+
+ def test_assigned_self_drop_compares_equal_to_itself
+ assert_template_result('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}')
+ end
+
+ def test_distinct_self_assignments_compare_equal_for_same_context
+ assert_template_result('T', '{% assign a = self %}{% assign b = self %}{% if a == b %}T{% else %}F{% endif %}')
+ end
+
+ def test_bare_self_compares_equal_to_bare_self
+ assert_template_result('T', '{% if self == self %}T{% else %}F{% endif %}')
+ end
+
+ def test_self_drop_with_strict_variables_does_not_raise_for_defined_var
+ t = Template.parse('{{ self.x }}')
+ result = t.render({ 'x' => 42 }, strict_variables: true)
+ assert_equal('42', result)
+ end
+
+ def test_self_drop_with_strict_variables_returns_nil_for_undefined_var
+ t = Template.parse('{{ self.x }}')
+ result = t.render({}, strict_variables: true)
+ assert_equal('', result)
+ end
+
+ def test_self_drop_can_be_passed_as_bare_drop_to_render
+ t = Template.parse('{{ self.x }}')
+ drop = SelfDrop.new(Context.new({ 'x' => 42 }))
+ result = t.render(drop)
+ assert_equal('42', result)
+ end
+end
diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb
index 88d525c33..94097ae1f 100644
--- a/test/integration/standard_filter_test.rb
+++ b/test/integration/standard_filter_test.rb
@@ -32,7 +32,7 @@ def initialize(value:)
attr_reader :value
def registers
- { @value => @context.registers[@value] }
+ "{#{@value.inspect}=>#{@context.registers[@value].inspect}}"
end
end
@@ -109,10 +109,14 @@ def test_slice
assert_raises(Liquid::ArgumentError) do
@filters.slice('foobar', 0, "")
end
+ assert_equal("", @filters.slice("foobar", 0, -(1 << 64)))
+ assert_equal("foobar", @filters.slice("foobar", 0, 1 << 63))
+ assert_equal("", @filters.slice("foobar", 1 << 63, 6))
+ assert_equal("", @filters.slice("foobar", -(1 << 63), 6))
end
def test_slice_on_arrays
- input = 'foobar'.split(//)
+ input = 'foobar'.split('')
assert_equal(%w(o o b), @filters.slice(input, 1, 3))
assert_equal(%w(o o b a r), @filters.slice(input, 1, 1000))
assert_equal(%w(), @filters.slice(input, 1, 0))
@@ -123,6 +127,22 @@ def test_slice_on_arrays
assert_equal(%w(r), @filters.slice(input, -1))
assert_equal(%w(), @filters.slice(input, 100, 10))
assert_equal(%w(), @filters.slice(input, -100, 10))
+ assert_equal([], @filters.slice(input, 0, -(1 << 64)))
+ assert_equal(input, @filters.slice(input, 0, 1 << 63))
+ assert_equal([], @filters.slice(input, 1 << 63, 6))
+ assert_equal([], @filters.slice(input, -(1 << 63), 6))
+ end
+
+ def test_find_on_empty_array
+ assert_nil(@filters.find([], 'foo', 'bar'))
+ end
+
+ def test_find_index_on_empty_array
+ assert_nil(@filters.find_index([], 'foo', 'bar'))
+ end
+
+ def test_has_on_empty_array
+ refute(@filters.has([], 'foo', 'bar'))
end
def test_truncate
@@ -132,6 +152,8 @@ def test_truncate
assert_equal('1234567890', @filters.truncate('1234567890'))
assert_equal("测试...", @filters.truncate("测试测试测试测试", 5))
assert_equal('12341', @filters.truncate("1234567890", 5, 1))
+ assert_equal("foobar", @filters.truncate("foobar", 1 << 63))
+ assert_equal("...", @filters.truncate("foobar", -(1 << 63)))
end
def test_split
@@ -142,6 +164,13 @@ def test_split
assert_equal(['A', 'Z'], @filters.split('A1Z', 1))
end
+ def test_squish_filter
+ assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar
+\t boo " | squish }})).render)
+ assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render)
+ assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render)
+ end
+
def test_escape
assert_equal('<strong>', @filters.escape(''))
assert_equal('1', @filters.escape(1))
@@ -166,7 +195,17 @@ def test_base64_encode
end
def test_base64_decode
- assert_equal('one two three', @filters.base64_decode('b25lIHR3byB0aHJlZQ=='))
+ decoded = @filters.base64_decode('b25lIHR3byB0aHJlZQ==')
+ assert_equal('one two three', decoded)
+ assert_equal(Encoding::UTF_8, decoded.encoding)
+
+ decoded = @filters.base64_decode('4pyF')
+ assert_equal('✅', decoded)
+ assert_equal(Encoding::UTF_8, decoded.encoding)
+
+ decoded = @filters.base64_decode("/w==")
+ assert_equal(Encoding::ASCII_8BIT, decoded.encoding)
+ assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded)
exception = assert_raises(Liquid::ArgumentError) do
@filters.base64_decode("invalidbase64")
@@ -178,16 +217,27 @@ def test_base64_decode
def test_base64_url_safe_encode
assert_equal(
'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8',
- @filters.base64_url_safe_encode('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|')
+ @filters.base64_url_safe_encode('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|'),
)
assert_equal('', @filters.base64_url_safe_encode(nil))
end
def test_base64_url_safe_decode
+ decoded = @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8')
assert_equal(
'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|',
- @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8')
+ decoded,
)
+ assert_equal(Encoding::UTF_8, decoded.encoding)
+
+ decoded = @filters.base64_url_safe_decode('4pyF')
+ assert_equal('✅', decoded)
+ assert_equal(Encoding::UTF_8, decoded.encoding)
+
+ decoded = @filters.base64_url_safe_decode("_w==")
+ assert_equal(Encoding::ASCII_8BIT, decoded.encoding)
+ assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded)
+
exception = assert_raises(Liquid::ArgumentError) do
@filters.base64_url_safe_decode("invalidbase64")
end
@@ -220,17 +270,15 @@ def test_truncatewords
assert_equal('one two three', @filters.truncatewords('one two three'))
assert_equal(
'Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”...',
- @filters.truncatewords('Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.', 15)
+ @filters.truncatewords('Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.', 15),
)
assert_equal("测试测试测试测试", @filters.truncatewords('测试测试测试测试', 5))
assert_equal('one two1', @filters.truncatewords("one two three", 2, 1))
assert_equal('one two three...', @filters.truncatewords("one two\tthree\nfour", 3))
assert_equal('one two...', @filters.truncatewords("one two three four", 2))
assert_equal('one...', @filters.truncatewords("one two three four", 0))
- exception = assert_raises(Liquid::ArgumentError) do
- @filters.truncatewords("one two three four", 1 << 31)
- end
- assert_equal("Liquid error: integer #{1 << 31} too big for truncatewords", exception.message)
+ assert_equal('one two three four', @filters.truncatewords("one two three four", 1 << 31))
+ assert_equal('one...', @filters.truncatewords("one two three four", -(1 << 32)))
end
def test_strip_html
@@ -252,6 +300,10 @@ def test_join
assert_equal('1121314', @filters.join([1, 2, 3, 4], 1))
end
+ def test_join_calls_to_liquid_on_each_element
+ assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
+ end
+
def test_sort
assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1]))
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
@@ -302,8 +354,8 @@ def test_sort_natural_when_property_is_sometimes_missing_puts_nils_last
{ "price" => "1", "handle" => "gamma" },
{ "price" => 2, "handle" => "epsilon" },
{ "price" => "4", "handle" => "alpha" },
- { "handle" => "delta" },
{ "handle" => "beta" },
+ { "handle" => "delta" },
]
assert_equal(expectation, @filters.sort_natural(input, "price"))
end
@@ -425,8 +477,11 @@ def test_legacy_reverse_hash
def test_map
assert_equal([1, 2, 3, 4], @filters.map([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], 'a'))
- assert_template_result('abc', "{{ ary | map:'foo' | map:'bar' }}",
- { 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] })
+ assert_template_result(
+ 'abc',
+ "{{ ary | map:'foo' | map:'bar' }}",
+ { 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] },
+ )
end
def test_map_doesnt_call_arbitrary_stuff
@@ -450,8 +505,11 @@ def test_map_calls_context=
end
def test_map_on_hashes
- assert_template_result("4217", '{{ thing | map: "foo" | map: "bar" }}',
- { "thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] } })
+ assert_template_result(
+ "4217",
+ '{{ thing | map: "foo" | map: "bar" }}',
+ { "thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] } },
+ )
end
def test_legacy_map_on_hashes_with_dynamic_key
@@ -469,8 +527,8 @@ def test_sort_calls_to_liquid
def test_map_over_proc
drop = TestDrop.new(value: "testfoo")
p = proc { drop }
- templ = '{{ procs | map: "value" }}'
- assert_template_result("testfoo", templ, { "procs" => [p] })
+ output = Liquid::Template.parse('{{ procs | map: "value" }}').render!({ "procs" => [p] })
+ assert_equal("testfoo", output)
end
def test_map_over_drops_returning_procs
@@ -482,12 +540,13 @@ def test_map_over_drops_returning_procs
"proc" => -> { "bar" },
},
]
- templ = '{{ drops | map: "proc" }}'
- assert_template_result("foobar", templ, { "drops" => drops })
+ output = Liquid::Template.parse('{{ drops | map: "proc" }}').render!({ "drops" => drops })
+ assert_equal("foobar", output)
end
def test_map_works_on_enumerables
- assert_template_result("123", '{{ foo | map: "foo" }}', { "foo" => TestEnumerable.new })
+ output = Liquid::Template.parse('{{ foo | map: "foo" }}').render!({ "foo" => TestEnumerable.new })
+ assert_equal("123", output)
end
def test_map_returns_empty_on_2d_input_array
@@ -502,12 +561,23 @@ def test_map_returns_empty_on_2d_input_array
end
end
- def test_map_returns_empty_with_no_property
+ def test_map_with_value_property
+ array = [
+ { "handle" => "alpha", "value" => "A" },
+ { "handle" => "beta", "value" => "B" },
+ { "handle" => "gamma", "value" => "C" }
+ ]
+
+ assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array })
+ end
+
+ def test_map_returns_input_with_no_property
foo = [
[1],
[2],
[3],
]
+
assert_raises(Liquid::ArgumentError) do
@filters.map(foo, nil)
end
@@ -564,6 +634,40 @@ def test_first_last
assert_nil(@filters.last([]))
end
+ def test_first_last_on_strings
+ # Ruby's String class does not have first/last methods by default.
+ # ActiveSupport adds String#first and String#last to return the first/last character.
+ # Liquid must work without ActiveSupport, so the first/last filters handle strings specially.
+ #
+ # This enables template patterns like:
+ # {{ product.title | first }} => "S" (for "Snowboard")
+ # {{ customer.name | last }} => "h" (for "Smith")
+ #
+ # Note: ActiveSupport returns "" for empty strings, not nil.
+ assert_equal('f', @filters.first('foo'))
+ assert_equal('o', @filters.last('foo'))
+ assert_equal('', @filters.first(''))
+ assert_equal('', @filters.last(''))
+ end
+
+ def test_first_last_on_unicode_strings
+ # Unicode strings should return the first/last grapheme cluster (character),
+ # not the first/last byte. Ruby's String#[] handles this correctly with index 0/-1.
+ # This ensures international text works properly:
+ # {{ korean_name | first }} => "고" (not a partial byte sequence)
+ assert_equal('고', @filters.first('고스트빈'))
+ assert_equal('빈', @filters.last('고스트빈'))
+ end
+
+ def test_first_last_on_strings_via_template
+ # Integration test to verify the filter works end-to-end in templates.
+ # Empty strings return empty output (nil renders as empty string).
+ assert_template_result('f', '{{ name | first }}', { 'name' => 'foo' })
+ assert_template_result('o', '{{ name | last }}', { 'name' => 'foo' })
+ assert_template_result('', '{{ name | first }}', { 'name' => '' })
+ assert_template_result('', '{{ name | last }}', { 'name' => '' })
+ end
+
def test_replace
assert_equal('b b b b', @filters.replace('a a a a', 'a', 'b'))
assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2))
@@ -791,21 +895,233 @@ def test_date_raises_nothing
assert_template_result('abc', "{{ 'abc' | date: '%D' }}")
end
+ def test_reject
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => true },
+ ]
+
+ template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}"
+ expected_output = "beta gamma"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_reject_with_value
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => true },
+ ]
+
+ template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}"
+ expected_output = "beta gamma"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_reject_with_false_value
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => true },
+ ]
+
+ template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}"
+ expected_output = "alpha delta"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_has
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => false },
+ ]
+
+ expected_output = "true"
+
+ assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
+ assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
+ end
+
+ def test_has_when_does_not_have_it
+ array = [
+ { "handle" => "alpha", "ok" => false },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => false },
+ ]
+
+ expected_output = "false"
+
+ assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
+ assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
+ end
+
+ def test_has_with_empty_arrays
+ template = <<~LIQUID
+ {%- assign has_product = products | has: 'title.content', 'Not found' -%}
+ {%- unless has_product -%}
+ Product not found.
+ {%- endunless -%}
+ LIQUID
+ expected_output = "Product not found."
+
+ assert_template_result(expected_output, template, { "products" => [] })
+ end
+
+ def test_has_with_false_value
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => true },
+ ]
+
+ template = "{{ array | has: 'ok', false }}"
+ expected_output = "true"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_has_with_false_value_when_does_not_have_it
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => true },
+ { "handle" => "gamma", "ok" => true },
+ { "handle" => "delta", "ok" => true },
+ ]
+
+ template = "{{ array | has: 'ok', false }}"
+ expected_output = "false"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_find_with_value
+ products = [
+ { "title" => "Pro goggles", "price" => 1299 },
+ { "title" => "Thermal gloves", "price" => 1499 },
+ { "title" => "Alpine jacket", "price" => 3999 },
+ { "title" => "Mountain boots", "price" => 3899 },
+ { "title" => "Safety helmet", "price" => 1999 }
+ ]
+
+ template = <<~LIQUID
+ {%- assign product = products | find: 'price', 3999 -%}
+ {{- product.title -}}
+ LIQUID
+ expected_output = "Alpine jacket"
+
+ assert_template_result(expected_output, template, { "products" => products })
+ end
+
+ def test_find_with_empty_arrays
+ template = <<~LIQUID
+ {%- assign product = products | find: 'title.content', 'Not found' -%}
+ {%- unless product -%}
+ Product not found.
+ {%- endunless -%}
+ LIQUID
+ expected_output = "Product not found."
+
+ assert_template_result(expected_output, template, { "products" => [] })
+ end
+
+ def test_find_index_with_value
+ products = [
+ { "title" => "Pro goggles", "price" => 1299 },
+ { "title" => "Thermal gloves", "price" => 1499 },
+ { "title" => "Alpine jacket", "price" => 3999 },
+ { "title" => "Mountain boots", "price" => 3899 },
+ { "title" => "Safety helmet", "price" => 1999 }
+ ]
+
+ template = <<~LIQUID
+ {%- assign index = products | find_index: 'price', 3999 -%}
+ {{- index -}}
+ LIQUID
+ expected_output = "2"
+
+ assert_template_result(expected_output, template, { "products" => products })
+ end
+
+ def test_find_index_with_empty_arrays
+ template = <<~LIQUID
+ {%- assign index = products | find_index: 'title.content', 'Not found' -%}
+ {%- unless index -%}
+ Index not found.
+ {%- endunless -%}
+ LIQUID
+ expected_output = "Index not found."
+
+ assert_template_result(expected_output, template, { "products" => [] })
+ end
+
def test_where
- input = [
+ array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
- expectation = [
+ template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}"
+ expected_output = "alpha delta"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_where_with_empty_string_is_a_no_op
+ environment = { "array" => ["alpha", "beta", "gamma"] }
+ expected_output = "alpha beta gamma"
+ template = "{{ array | where: '' | join: ' ' }}"
+
+ assert_template_result(expected_output, template, environment)
+ end
+
+ def test_where_with_nil_is_a_no_op
+ environment = { "array" => ["alpha", "beta", "gamma"] }
+ template = "{{ array | where: nil | join: ' ' }}"
+
+ assert_raises(Liquid::ArgumentError) do
+ assert_template_result("alpha beta gamma", template, environment)
+ end
+ end
+
+ def test_where_with_value
+ array = [
{ "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
- assert_equal(expectation, @filters.where(input, "ok", true))
- assert_equal(expectation, @filters.where(input, "ok"))
+ template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}"
+ expected_output = "alpha delta"
+
+ assert_template_result(expected_output, template, { "array" => array })
+ end
+
+ def test_where_with_false_value
+ array = [
+ { "handle" => "alpha", "ok" => true },
+ { "handle" => "beta", "ok" => false },
+ { "handle" => "gamma", "ok" => false },
+ { "handle" => "delta", "ok" => true },
+ ]
+
+ template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}"
+ expected_output = "beta gamma"
+
+ assert_template_result(expected_output, template, { "array" => array })
end
def test_where_string_keys
@@ -865,6 +1181,8 @@ def test_where_array_of_only_unindexable_values
end
def test_all_filters_never_raise_non_liquid_exception
+ skip("too slow on non-CRuby due to many exceptions") unless RUBY_ENGINE == 'ruby'
+
test_drop = TestDrop.new(value: "test")
test_drop.context = Context.new
test_enum = TestEnumerable.new
@@ -913,6 +1231,135 @@ def test_where_no_target_value
assert_equal([{ "foo" => true }, { "foo" => "for sure" }], @filters.where(input, "foo"))
end
+ def test_sum_with_all_numbers
+ input = [1, 2]
+
+ assert_equal(3, @filters.sum(input))
+ assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do
+ @filters.sum(input, "quantity")
+ end
+ end
+
+ def test_sum_with_numeric_strings
+ input = [1, 2, "3", "4"]
+
+ assert_equal(10, @filters.sum(input))
+ assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do
+ @filters.sum(input, "quantity")
+ end
+ end
+
+ def test_sum_with_nested_arrays
+ input = [1, [2, [3, 4]]]
+
+ assert_equal(10, @filters.sum(input))
+ assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do
+ @filters.sum(input, "quantity")
+ end
+ end
+
+ def test_sum_with_indexable_map_values
+ input = [{ "quantity" => 1 }, { "quantity" => 2, "weight" => 3 }, { "weight" => 4 }]
+
+ assert_equal(0, @filters.sum(input))
+ assert_equal(3, @filters.sum(input, "quantity"))
+ assert_equal(7, @filters.sum(input, "weight"))
+ assert_equal(0, @filters.sum(input, "subtotal"))
+ end
+
+ def test_sum_with_indexable_non_map_values
+ input = [1, [2], "foo", { "quantity" => 3 }]
+
+ assert_equal(3, @filters.sum(input))
+ assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do
+ @filters.sum(input, "quantity")
+ end
+ end
+
+ def test_sum_with_unindexable_values
+ input = [1, true, nil, { "quantity" => 2 }]
+
+ assert_equal(1, @filters.sum(input))
+ assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do
+ @filters.sum(input, "quantity")
+ end
+ end
+
+ def test_sum_without_property_calls_to_liquid
+ t = TestThing.new
+ Liquid::Template.parse('{{ foo | sum }}').render("foo" => [t])
+ assert(t.foo > 0)
+ end
+
+ def test_sum_with_property_calls_to_liquid_on_property_values
+ t = TestThing.new
+ Liquid::Template.parse('{{ foo | sum: "quantity" }}').render("foo" => [{ "quantity" => t }])
+ assert(t.foo > 0)
+ end
+
+ def test_sum_of_floats
+ input = [0.1, 0.2, 0.3]
+ assert_equal(0.6, @filters.sum(input))
+ assert_template_result("0.6", "{{ input | sum }}", { "input" => input })
+ end
+
+ def test_sum_of_negative_floats
+ input = [0.1, 0.2, -0.3]
+ assert_equal(0.0, @filters.sum(input))
+ assert_template_result("0.0", "{{ input | sum }}", { "input" => input })
+ end
+
+ def test_sum_with_float_strings
+ input = [0.1, "0.2", "0.3"]
+ assert_equal(0.6, @filters.sum(input))
+ assert_template_result("0.6", "{{ input | sum }}", { "input" => input })
+ end
+
+ def test_sum_resulting_in_negative_float
+ input = [0.1, -0.2, -0.3]
+ assert_equal(-0.4, @filters.sum(input))
+ assert_template_result("-0.4", "{{ input | sum }}", { "input" => input })
+ end
+
+ def test_sum_with_floats_and_indexable_map_values
+ input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }]
+ assert_equal(0.0, @filters.sum(input))
+ assert_equal(1.2, @filters.sum(input, "quantity"))
+ assert_equal(0.1, @filters.sum(input, "weight"))
+ assert_equal(0.0, @filters.sum(input, "subtotal"))
+ assert_template_result("0", "{{ input | sum }}", { "input" => input })
+ assert_template_result("1.2", "{{ input | sum: 'quantity' }}", { "input" => input })
+ assert_template_result("0.1", "{{ input | sum: 'weight' }}", { "input" => input })
+ assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
+ end
+
+ def test_sum_with_non_string_property
+ input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }]
+
+ assert_equal(1, @filters.sum(input, true))
+ assert_equal(0.2, @filters.sum(input, 1.0))
+ assert_equal(-0.3, @filters.sum(input, 1))
+ assert_equal(0.4, @filters.sum(input, 1..5))
+ assert_equal(0, @filters.sum(input, nil))
+ assert_equal(0, @filters.sum(input, ""))
+ end
+
+ def test_uniq_with_to_liquid_value
+ input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"]
+ expected = [StringDrop.new("foo"), StringDrop.new("bar")]
+ result = @filters.uniq(input)
+
+ assert_equal(expected, result)
+ end
+
+ def test_uniq_with_to_liquid_value_pick_correct_classes
+ input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")]
+ expected = [String, StringDrop]
+ result = @filters.uniq(input).map(&:class)
+
+ assert_equal(expected, result)
+ end
+
private
def with_timezone(tz)
diff --git a/test/integration/tags/break_tag_test.rb b/test/integration/tags/break_tag_test.rb
index a67a8b535..0a02d581d 100644
--- a/test/integration/tags/break_tag_test.rb
+++ b/test/integration/tags/break_tag_test.rb
@@ -9,8 +9,8 @@ class BreakTagTest < Minitest::Test
# block
def test_break_with_no_block
assigns = { 'i' => 1 }
- markup = '{% break %}'
- expected = ''
+ markup = 'before{% break %}after'
+ expected = 'before'
assert_template_result(expected, markup, assigns)
end
diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb
new file mode 100644
index 000000000..b0cdf606f
--- /dev/null
+++ b/test/integration/tags/cycle_tag_test.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class CycleTagTest < Minitest::Test
+ def test_simple_cycle_inside_for_loop
+ template = <<~LIQUID
+ {%- for i in (1..3) -%}
+ {%- cycle '1', '2', '3' -%}
+ {%- endfor -%}
+ LIQUID
+
+ assert_template_result("123", template)
+ end
+
+ def test_cycle_with_variables_inside_for_loop
+ template = <<~LIQUID
+ {%- assign a = 1 -%}
+ {%- assign b = 2 -%}
+ {%- assign c = 3 -%}
+ {%- for i in (1..3) -%}
+ {% cycle a, b, c %}
+ {%- endfor -%}
+ LIQUID
+
+ assert_template_result("123", template)
+ end
+
+ def test_cycle_named_groups_string
+ template = <<~LIQUID
+ {%- for i in (1..3) -%}
+ {%- cycle 'placeholder1': 1, 2, 3 -%}
+ {%- cycle 'placeholder2': 1, 2, 3 -%}
+ {%- endfor -%}
+ LIQUID
+
+ assert_template_result("112233", template)
+ end
+
+ def test_cycle_named_groups_vlookup
+ template = <<~LIQUID
+ {%- assign placeholder1 = 'placeholder1' -%}
+ {%- assign placeholder2 = 'placeholder2' -%}
+ {%- for i in (1..3) -%}
+ {%- cycle placeholder1: 1, 2, 3 -%}
+ {%- cycle placeholder2: 1, 2, 3 -%}
+ {%- endfor -%}
+ LIQUID
+
+ assert_template_result("112233", template)
+ end
+
+ def test_unnamed_cycle_have_independent_counters_when_used_with_lookups
+ template = <<~LIQUID
+ {%- assign a = "1" -%}
+ {%- for i in (1..3) -%}
+ {%- cycle a, "2" -%}
+ {%- cycle a, "2" -%}
+ {%- endfor -%}
+ LIQUID
+
+ assert_template_result("112211", template)
+ end
+
+ def test_unnamed_cycle_dependent_counter_when_used_with_literal_values
+ template = <<~LIQUID
+ {%- cycle "1", "2" -%}
+ {%- cycle "1", "2" -%}
+ {%- cycle "1", "2" -%}
+ LIQUID
+
+ assert_template_result("121", template)
+ end
+
+ def test_optional_trailing_comma
+ template = <<~LIQUID
+ {%- cycle "1", "2", -%}
+ {%- cycle "1", "2", -%}
+ {%- cycle "1", "2", -%}
+ {%- cycle "1", -%}
+ LIQUID
+
+ assert_template_result("1211", template)
+ end
+
+ def test_cycle_tag_without_arguments
+ error = assert_raises(Liquid::SyntaxError) do
+ Template.parse("{% cycle %}")
+ end
+
+ assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
+ end
+
+ def test_cycle_tag_with_error_mode
+ # QuotedFragment is more permissive than what Parser#expression allows.
+ template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
+ template2 = "{% cycle .5: 'a', 'b' %}"
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result("b", template1)
+ assert_template_result("a", template2)
+ end
+
+ with_error_modes(:strict2) do
+ error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
+ error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
+
+ assert_match(/Liquid syntax error:/, error1.message)
+ assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message)
+ end
+ end
+
+ def test_cycle_with_trailing_elements
+ assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
+
+ template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
+ template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}"
+ template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}"
+ template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}"
+ template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}"
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result("a", template1)
+ assert_template_result("a", template2)
+ assert_template_result("a", template3)
+ assert_template_result("N", template4)
+ assert_template_result("N", template5)
+ end
+
+ with_error_modes(:strict2) do
+ error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
+ error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
+ error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
+ error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
+ error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
+
+ expected_error = /Expected end_of_string but found/
+
+ assert_match(expected_error, error1.message)
+ assert_match(expected_error, error2.message)
+ assert_match(expected_error, error3.message)
+ assert_match(expected_error, error4.message)
+ assert_match(expected_error, error5.message)
+ end
+ end
+
+ def test_cycle_name_with_invalid_expression
+ template = <<~LIQUID
+ {% for i in (1..3) %}
+ {% cycle foo=>bar: "a", "b" %}
+ {% endfor %}
+ LIQUID
+
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_cycle_variable_with_invalid_expression
+ template = <<~LIQUID
+ {% for i in (1..3) %}
+ {% cycle foo=>bar, "a", "b" %}
+ {% endfor %}
+ LIQUID
+
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+end
diff --git a/test/integration/tags/for_tag_test.rb b/test/integration/tags/for_tag_test.rb
index 68fd8321b..36510eeab 100644
--- a/test/integration/tags/for_tag_test.rb
+++ b/test/integration/tags/for_tag_test.rb
@@ -73,9 +73,11 @@ def test_for_with_variable
def test_for_helpers
assigns = { 'array' => [1, 2, 3] }
- assert_template_result(' 1/3 2/3 3/3 ',
+ assert_template_result(
+ ' 1/3 2/3 3/3 ',
'{%for item in array%} {{forloop.index}}/{{forloop.length}} {%endfor%}',
- assigns)
+ assigns,
+ )
assert_template_result(' 1 2 3 ', '{%for item in array%} {{forloop.index}} {%endfor%}', assigns)
assert_template_result(' 0 1 2 ', '{%for item in array%} {{forloop.index0}} {%endfor%}', assigns)
assert_template_result(' 2 1 0 ', '{%for item in array%} {{forloop.rindex0}} {%endfor%}', assigns)
@@ -86,9 +88,11 @@ def test_for_helpers
def test_for_and_if
assigns = { 'array' => [1, 2, 3] }
- assert_template_result('+--',
+ assert_template_result(
+ '+--',
'{%for item in array%}{% if forloop.first %}+{% else %}-{% endif %}{%endfor%}',
- assigns)
+ assigns,
+ )
end
def test_for_else
@@ -103,6 +107,7 @@ def test_limiting
assert_template_result('1234', '{%for i in array limit:4 %}{{ i }}{%endfor%}', assigns)
assert_template_result('3456', '{%for i in array limit:4 offset:2 %}{{ i }}{%endfor%}', assigns)
assert_template_result('3456', '{%for i in array limit: 4 offset: 2 %}{{ i }}{%endfor%}', assigns)
+ assert_template_result('3456', '{%for i in array, limit: 4, offset: 2 %}{{ i }}{%endfor%}', assigns)
end
def test_limiting_with_invalid_limit
@@ -263,6 +268,19 @@ def test_for_with_break
assert_template_result(expected, markup, assigns)
end
+ def test_for_with_break_after_nested_loop
+ source = <<~LIQUID.chomp
+ {% for i in (1..2) -%}
+ {% for j in (1..2) -%}
+ {{ i }}-{{ j }},
+ {%- endfor -%}
+ {% break -%}
+ {% endfor -%}
+ after
+ LIQUID
+ assert_template_result("1-1,1-2,after", source)
+ end
+
def test_for_with_continue
assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5] } }
@@ -311,15 +329,20 @@ def test_for_tag_string
# ruby 1.9.3 no longer supports .each on String though we mimic
# the functionality for backwards compatibility
- assert_template_result('test string',
+ assert_template_result(
+ 'test string',
'{%for val in string%}{{val}}{%endfor%}',
- { 'string' => "test string" })
+ { 'string' => "test string" },
+ )
- assert_template_result('test string',
+ assert_template_result(
+ 'test string',
'{%for val in string limit:1%}{{val}}{%endfor%}',
- { 'string' => "test string" })
+ { 'string' => "test string" },
+ )
- assert_template_result('val-string-1-1-0-1-0-true-true-test string',
+ assert_template_result(
+ 'val-string-1-1-0-1-0-true-true-test string',
'{%for val in string%}' \
'{{forloop.name}}-' \
'{{forloop.index}}-' \
@@ -330,23 +353,28 @@ def test_for_tag_string
'{{forloop.first}}-' \
'{{forloop.last}}-' \
'{{val}}{%endfor%}',
- { 'string' => "test string" })
+ { 'string' => "test string" },
+ )
end
def test_for_parentloop_references_parent_loop
- assert_template_result('1.1 1.2 1.3 2.1 2.2 2.3 ',
+ assert_template_result(
+ '1.1 1.2 1.3 2.1 2.2 2.3 ',
'{% for inner in outer %}{% for k in inner %}' \
'{{ forloop.parentloop.index }}.{{ forloop.index }} ' \
'{% endfor %}{% endfor %}',
- { 'outer' => [[1, 1, 1], [1, 1, 1]] })
+ { 'outer' => [[1, 1, 1], [1, 1, 1]] },
+ )
end
def test_for_parentloop_nil_when_not_present
- assert_template_result('.1 .2 ',
+ assert_template_result(
+ '.1 .2 ',
'{% for inner in outer %}' \
'{{ forloop.parentloop.index }}.{{ forloop.index }} ' \
'{% endfor %}',
- { 'outer' => [[1, 1, 1], [1, 1, 1]] })
+ { 'outer' => [[1, 1, 1], [1, 1, 1]] },
+ )
end
def test_inner_for_over_empty_input
@@ -438,29 +466,84 @@ def test_for_cleans_up_registers
assert(context.registers[:for_stack].empty?)
end
- def test_instrument_for_offset_continue
- assert_usage_increment('for_offset_continue') do
- Template.parse('{% for item in items offset:continue %}{{item}}{% endfor %}')
- end
+ def test_integer_range_is_not_materialized_and_charges_empty_iterations
+ range = bounded_integer_range_with_tripwires
+ template = Template.parse('{% for i in numbers %}{% endfor %}')
+ template.resource_limits.render_score_limit = 3
- assert_usage_increment('for_offset_continue', times: 0) do
- Template.parse('{% for item in items offset:2 %}{{item}}{% endfor %}')
- end
+ assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
+
+ assert(template.resource_limits.reached?)
+ assert_equal(4, template.resource_limits.render_score)
+ end
+
+ def test_integer_range_uses_arithmetic_offsets_reversal_and_metadata
+ range = bounded_integer_range_with_tripwires
+ template = Template.parse(
+ '{% for i in numbers reversed offset:997 limit:2 %}{{ forloop.length }}:{{ i }}{% endfor %}',
+ )
+ template.resource_limits.render_score_limit = 10
+
+ assert_equal('2:9992:998', template.render!('numbers' => range))
+ end
+
+ def test_range_scores_cannot_be_bypassed_by_repeated_renders
+ template = Template.parse('{% for i in (1..2) %}{% endfor %}')
+ template.resource_limits.cumulative_render_score_limit = 3
+ assert_equal('', template.render!)
+ assert_raises(Liquid::MemoryError) { template.render! }
end
- def test_instrument_forloop_drop_name
- assigns = { 'items' => [1, 2, 3, 4, 5] }
+ def test_range_break_charges_only_visited_items_and_preserves_full_metadata
+ range = bounded_integer_range_with_tripwires
+ template = Template.parse(
+ '{% for i in numbers reversed %}{{ forloop.length }}:{{ i }}{% break %}{% endfor %}',
+ )
+ template.resource_limits.render_score_limit = 10
- assert_usage_increment('forloop_drop_name', times: 5) do
- Template.parse('{% for item in items %}{{forloop.name}}{% endfor %}').render!(assigns)
+ assert_equal('1000:1000', template.render!('numbers' => range))
+ assert_operator(template.resource_limits.render_score, :<=, 10)
+ end
+
+ def test_range_subclass_uses_its_custom_each
+ range = Class.new(Range) do
+ def each
+ yield 10
+ yield 20
+ end
+ end.new(nil, 3)
+
+ assert_template_result('1020', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
+ end
+
+ def test_range_subclass_custom_to_a_is_honored_for_finite_and_open_bounds
+ range_class = Class.new(Range) do
+ def to_a
+ [42]
+ end
end
- assert_usage_increment('forloop_drop_name', times: 0) do
- Template.parse('{% for item in items %}{{forloop.index}}{% endfor %}').render!(assigns)
+ [range_class.new(1, 3), range_class.new(nil, 3), range_class.new(1, nil)].each do |range|
+ assert_template_result('42', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
end
+ end
+
+ def test_endless_range_remains_unsupported_with_a_limit
+ template = Template.parse('{% for i in numbers limit:2 %}{{ i }}{% endfor %}')
+
+ assert_raises(RangeError) { template.render!('numbers' => (1..)) }
+ end
+
+ def test_endless_range_subclass_with_custom_each_remains_unsupported
+ range = Class.new(Range) do
+ def each
+ yield 10
+ yield 20
+ end
+ end.new(1, nil)
- assert_usage_increment('forloop_drop_name', times: 0) do
- Template.parse('{% for item in items %}{{item}}{% endfor %}').render!(assigns)
+ assert_raises(RangeError) do
+ Template.parse('{% for i in numbers %}{{ i }}{% endfor %}').render!('numbers' => range)
end
end
end
diff --git a/test/integration/tags/if_else_tag_test.rb b/test/integration/tags/if_else_tag_test.rb
index b550ea9e0..3f04f2b8d 100644
--- a/test/integration/tags/if_else_tag_test.rb
+++ b/test/integration/tags/if_else_tag_test.rb
@@ -7,8 +7,10 @@ class IfElseTagTest < Minitest::Test
def test_if
assert_template_result(' ', ' {% if false %} this text should not go into the output {% endif %} ')
- assert_template_result(' this text should go into the output ',
- ' {% if true %} this text should go into the output {% endif %} ')
+ assert_template_result(
+ ' this text should go into the output ',
+ ' {% if true %} this text should go into the output {% endif %} ',
+ )
assert_template_result(' you rock ?', '{% if false %} you suck {% endif %} {% if true %} you rock {% endif %}?')
end
@@ -51,18 +53,22 @@ def test_comparison_of_strings_containing_and_or_or
def test_comparison_of_expressions_starting_with_and_or_or
assigns = { 'order' => { 'items_count' => 0 }, 'android' => { 'name' => 'Roy' } }
- assert_template_result("YES",
+ assert_template_result(
+ "YES",
"{% if android.name == 'Roy' %}YES{% endif %}",
- assigns)
- assert_template_result("YES",
+ assigns,
+ )
+ assert_template_result(
+ "YES",
"{% if order.items_count == 0 %}YES{% endif %}",
- assigns)
+ assigns,
+ )
end
def test_if_and
assert_template_result(' YES ', '{% if true and true %} YES {% endif %}')
assert_template_result('', '{% if false and true %} YES {% endif %}')
- assert_template_result('', '{% if false and true %} YES {% endif %}')
+ assert_template_result('', '{% if true and false %} YES {% endif %}')
end
def test_hash_miss_generates_false
@@ -155,8 +161,10 @@ def test_operators_are_ignored_unless_isolated
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
- assert_template_result('yes',
- %({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}))
+ assert_template_result(
+ 'yes',
+ %({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}),
+ )
ensure
Condition.operators['contains'] = original_op
end
diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb
index 3f05b47bf..bd4085f21 100644
--- a/test/integration/tags/include_tag_test.rb
+++ b/test/integration/tags/include_tag_test.rb
@@ -3,44 +3,13 @@
require 'test_helper'
class TestFileSystem
- def read_template_file(template_path)
- case template_path
- when "product"
- "Product: {{ product.title }} "
-
- when "product_alias"
- "Product: {{ product.title }} "
-
- when "locale_variables"
- "Locale: {{echo1}} {{echo2}}"
-
- when "variant"
- "Variant: {{ variant.title }}"
-
- when "nested_template"
- "{% include 'header' %} {% include 'body' %} {% include 'footer' %}"
-
- when "body"
- "body {% include 'body_detail' %}"
-
- when "nested_product_template"
- "Product: {{ nested_product_template.title }} {%include 'details'%} "
-
- when "recursively_nested_template"
- "-{% include 'recursively_nested_template' %}"
+ PARTIALS = {
+ "nested_template" => "{% include 'header' %} {% include 'body' %} {% include 'footer' %}",
+ "body" => "body {% include 'body_detail' %}",
+ }
- when "pick_a_source"
- "from TestFileSystem"
-
- when 'assignments'
- "{% assign foo = 'bar' %}"
-
- when 'break'
- "{% break %}"
-
- else
- template_path
- end
+ def read_template_file(template_path)
+ PARTIALS[template_path] || template_path
end
end
@@ -80,75 +49,122 @@ def render_to_output_buffer(_context, output)
class IncludeTagTest < Minitest::Test
include Liquid
- def setup
- Liquid::Template.file_system = TestFileSystem.new
- end
-
def test_include_tag_looks_for_file_system_in_registers_first
- assert_equal('from OtherFileSystem',
- Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: OtherFileSystem.new }))
+ assert_equal(
+ 'from OtherFileSystem',
+ Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: OtherFileSystem.new }),
+ )
end
def test_include_tag_with
- assert_template_result("Product: Draft 151cm ",
+ assert_template_result(
+ "Product: Draft 151cm ",
"{% include 'product' with products[0] %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: { "product" => "Product: {{ product.title }} " },
+ )
end
def test_include_tag_with_alias
- assert_template_result("Product: Draft 151cm ",
+ assert_template_result(
+ "Product: Draft 151cm ",
"{% include 'product_alias' with products[0] as product %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: { "product_alias" => "Product: {{ product.title }} " },
+ )
end
def test_include_tag_for_alias
- assert_template_result("Product: Draft 151cm Product: Element 155cm ",
+ assert_template_result(
+ "Product: Draft 151cm Product: Element 155cm ",
"{% include 'product_alias' for products as product %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: { "product_alias" => "Product: {{ product.title }} " },
+ )
end
def test_include_tag_with_default_name
- assert_template_result("Product: Draft 151cm ",
- "{% include 'product' %}", { "product" => { 'title' => 'Draft 151cm' } })
+ assert_template_result(
+ "Product: Draft 151cm ",
+ "{% include 'product' %}",
+ { "product" => { 'title' => 'Draft 151cm' } },
+ partials: { "product" => "Product: {{ product.title }} " },
+ )
end
def test_include_tag_for
- assert_template_result("Product: Draft 151cm Product: Element 155cm ",
+ assert_template_result(
+ "Product: Draft 151cm Product: Element 155cm ",
"{% include 'product' for products %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: { "product" => "Product: {{ product.title }} " },
+ )
end
def test_include_tag_with_local_variables
- assert_template_result("Locale: test123 ", "{% include 'locale_variables' echo1: 'test123' %}")
+ assert_template_result(
+ "Locale: test123 ",
+ "{% include 'locale_variables' echo1: 'test123' %}",
+ partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" },
+ )
end
def test_include_tag_with_multiple_local_variables
- assert_template_result("Locale: test123 test321",
- "{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}")
+ assert_template_result(
+ "Locale: test123 test321",
+ "{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}",
+ partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" },
+ )
end
def test_include_tag_with_multiple_local_variables_from_context
- assert_template_result("Locale: test123 test321",
+ assert_template_result(
+ "Locale: test123 test321",
"{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}",
- { 'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321' } })
+ { 'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321' } },
+ partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" },
+ )
end
def test_included_templates_assigns_variables
- assert_template_result("bar", "{% include 'assignments' %}{{ foo }}")
+ assert_template_result(
+ "bar",
+ "{% include 'assignments' %}{{ foo }}",
+ partials: { 'assignments' => "{% assign foo = 'bar' %}" },
+ )
end
def test_nested_include_tag
- assert_template_result("body body_detail", "{% include 'body' %}")
+ partials = { "body" => "body {% include 'body_detail' %}", "body_detail" => "body_detail" }
+ assert_template_result("body body_detail", "{% include 'body' %}", partials: partials)
- assert_template_result("header body body_detail footer", "{% include 'nested_template' %}")
+ partials = partials.merge({
+ "nested_template" => "{% include 'header' %} {% include 'body' %} {% include 'footer' %}",
+ "header" => "header",
+ "footer" => "footer",
+ })
+ assert_template_result("header body body_detail footer", "{% include 'nested_template' %}", partials: partials)
end
def test_nested_include_with_variable
- assert_template_result("Product: Draft 151cm details ",
- "{% include 'nested_product_template' with product %}", { "product" => { "title" => 'Draft 151cm' } })
-
- assert_template_result("Product: Draft 151cm details Product: Element 155cm details ",
- "{% include 'nested_product_template' for products %}", { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] })
+ partials = {
+ "nested_product_template" => "Product: {{ nested_product_template.title }} {%include 'details'%} ",
+ "details" => "details",
+ }
+
+ assert_template_result(
+ "Product: Draft 151cm details ",
+ "{% include 'nested_product_template' with product %}",
+ { "product" => { "title" => 'Draft 151cm' } },
+ partials: partials,
+ )
+
+ assert_template_result(
+ "Product: Draft 151cm details Product: Element 155cm details ",
+ "{% include 'nested_product_template' for products %}",
+ { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] },
+ partials: partials,
+ )
end
def test_recursively_included_template_does_not_produce_endless_loop
@@ -158,49 +174,103 @@ def read_template_file(_template_path)
end
end
- Liquid::Template.file_system = infinite_file_system.new
+ env = Liquid::Environment.build(file_system: infinite_file_system.new)
assert_raises(Liquid::StackLevelError) do
- Template.parse("{% include 'loop' %}").render!
+ Template.parse("{% include 'loop' %}", environment: env).render!
end
end
def test_dynamically_choosen_template
- assert_template_result("Test123", "{% include template %}", { "template" => 'Test123' })
- assert_template_result("Test321", "{% include template %}", { "template" => 'Test321' })
+ assert_template_result(
+ "Test123",
+ "{% include template %}",
+ { "template" => 'Test123' },
+ partials: { "Test123" => "Test123" },
+ )
+
+ assert_template_result(
+ "Test321",
+ "{% include template %}",
+ { "template" => 'Test321' },
+ partials: { "Test321" => "Test321" },
+ )
+
+ assert_template_result(
+ "Product: Draft 151cm ",
+ "{% include template for product %}",
+ { "template" => 'product', 'product' => { 'title' => 'Draft 151cm' } },
+ partials: { "product" => "Product: {{ product.title }} " },
+ )
+ end
+
+ def test_strict2_parsing_errors
+ with_error_modes(:lax, :strict) do
+ assert_template_result(
+ 'hello value1 value2',
+ '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
+ partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
+ )
+ end
+
+ with_error_modes(:strict2) do
+ assert_syntax_error(
+ '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
+ )
+ assert_syntax_error(
+ '{% include "snippet" | filter %}',
+ )
+ end
+ end
- assert_template_result("Product: Draft 151cm ", "{% include template for product %}",
- { "template" => 'product', 'product' => { 'title' => 'Draft 151cm' } })
+ def test_optional_commas
+ partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
+ assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
+ assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
+ assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
end
def test_include_tag_caches_second_read_of_same_partial
file_system = CountingFileSystem.new
- assert_equal('from CountingFileSystemfrom CountingFileSystem',
- Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }))
+ environment = Liquid::Environment.build(file_system: file_system)
+ assert_equal(
+ 'from CountingFileSystemfrom CountingFileSystem',
+ Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}", environment: environment).render!({}, registers: { file_system: file_system }),
+ )
assert_equal(1, file_system.count)
end
def test_include_tag_doesnt_cache_partials_across_renders
file_system = CountingFileSystem.new
- assert_equal('from CountingFileSystem',
- Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }))
+ assert_equal(
+ 'from CountingFileSystem',
+ Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }),
+ )
assert_equal(1, file_system.count)
- assert_equal('from CountingFileSystem',
- Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }))
+ assert_equal(
+ 'from CountingFileSystem',
+ Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }),
+ )
assert_equal(2, file_system.count)
end
def test_include_tag_within_if_statement
- assert_template_result("foo_if_true", "{% if true %}{% include 'foo_if_true' %}{% endif %}")
+ assert_template_result(
+ "foo_if_true",
+ "{% if true %}{% include 'foo_if_true' %}{% endif %}",
+ partials: { "foo_if_true" => "foo_if_true" },
+ )
end
def test_custom_include_tag
original_tag = Liquid::Template.tags['include']
Liquid::Template.tags['include'] = CustomInclude
begin
- assert_equal("custom_foo",
- Template.parse("{% include 'custom_foo' %}").render!)
+ assert_equal(
+ "custom_foo",
+ Template.parse("{% include 'custom_foo' %}").render!,
+ )
ensure
Liquid::Template.tags['include'] = original_tag
end
@@ -210,59 +280,92 @@ def test_custom_include_tag_within_if_statement
original_tag = Liquid::Template.tags['include']
Liquid::Template.tags['include'] = CustomInclude
begin
- assert_equal("custom_foo_if_true",
- Template.parse("{% if true %}{% include 'custom_foo_if_true' %}{% endif %}").render!)
+ assert_equal(
+ "custom_foo_if_true",
+ Template.parse("{% if true %}{% include 'custom_foo_if_true' %}{% endif %}").render!,
+ )
ensure
Liquid::Template.tags['include'] = original_tag
end
end
def test_does_not_add_error_in_strict_mode_for_missing_variable
- Liquid::Template.file_system = TestFileSystem.new
+ env = Liquid::Environment.build(file_system: TestFileSystem.new)
- a = Liquid::Template.parse(' {% include "nested_template" %}')
+ a = Liquid::Template.parse(' {% include "nested_template" %}', environment: env)
a.render!
assert_empty(a.errors)
end
def test_passing_options_to_included_templates
+ env = Liquid::Environment.build(file_system: TestFileSystem.new)
+
assert_raises(Liquid::SyntaxError) do
- Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}')
+ Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
end
- with_error_mode(:lax) do
- assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true).render!("template" => '{{ "X" || downcase }}'))
+ with_error_modes(:lax) do
+ assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
end
assert_raises(Liquid::SyntaxError) do
- Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale]).render!("template" => '{{ "X" || downcase }}')
+ Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end
- with_error_mode(:lax) do
- assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode]).render!("template" => '{{ "X" || downcase }}'))
+ with_error_modes(:lax) do
+ assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
end
end
def test_render_raise_argument_error_when_template_is_undefined
- assert_raises(Liquid::ArgumentError) do
- template = Liquid::Template.parse('{% include undefined_variable %}')
- template.render!
- end
- assert_raises(Liquid::ArgumentError) do
- template = Liquid::Template.parse('{% include nil %}')
- template.render!
- end
+ assert_template_result(
+ "Liquid error (line 1): Argument error in tag 'include' - Illegal template name",
+ "{% include undefined_variable %}",
+ render_errors: true,
+ )
+
+ assert_template_result(
+ "Liquid error (line 1): Argument error in tag 'include' - Illegal template name",
+ "{% include nil %}",
+ render_errors: true,
+ )
+ end
+
+ def test_render_raise_argument_error_when_template_is_not_a_string
+ assert_template_result(
+ "Liquid error (line 1): Argument error in tag 'include' - Illegal template name",
+ "{% include 123 %}",
+ render_errors: true,
+ )
end
def test_including_via_variable_value
- assert_template_result("from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}")
+ assert_template_result(
+ "from TestFileSystem",
+ "{% assign page = 'pick_a_source' %}{% include page %}",
+ partials: { "pick_a_source" => "from TestFileSystem" },
+ )
- assert_template_result("Product: Draft 151cm ", "{% assign page = 'product' %}{% include page %}",
- { "product" => { 'title' => 'Draft 151cm' } })
+ partials = { "product" => "Product: {{ product.title }} " }
- assert_template_result("Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}",
- { "foo" => { 'title' => 'Draft 151cm' } })
+ assert_template_result(
+ "Product: Draft 151cm ",
+ "{% assign page = 'product' %}{% include page %}",
+ { "product" => { 'title' => 'Draft 151cm' } },
+ partials: partials,
+ )
+
+ assert_template_result(
+ "Product: Draft 151cm ",
+ "{% assign page = 'product' %}{% include page for foo %}",
+ { "foo" => { 'title' => 'Draft 151cm' } },
+ partials: partials,
+ )
end
def test_including_with_strict_variables
- template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn)
+ env = Liquid::Environment.build(
+ file_system: StubFileSystem.new('simple' => 'simple'),
+ )
+
+ template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template.render(nil, strict_variables: true)
assert_equal([], template.errors)
@@ -270,6 +373,115 @@ def test_including_with_strict_variables
def test_break_through_include
assert_template_result("1", "{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}")
- assert_template_result("1", "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}")
+ assert_template_result(
+ "1",
+ "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}",
+ partials: { 'break' => "{% break %}" },
+ )
+ end
+
+ def test_render_tag_renders_error_with_template_name
+ assert_template_result(
+ 'Liquid error (foo line 1): standard error',
+ "{% include 'foo' with errors %}",
+ { 'errors' => ErrorDrop.new },
+ partials: { 'foo' => '{{ foo.standard_error }}' },
+ render_errors: true,
+ )
+ end
+
+ def test_render_tag_renders_error_with_template_name_from_template_factory
+ assert_template_result(
+ 'Liquid error (some/path/foo line 1): standard error',
+ "{% include 'foo' with errors %}",
+ { 'errors' => ErrorDrop.new },
+ partials: { 'foo' => '{{ foo.standard_error }}' },
+ template_factory: StubTemplateFactory.new,
+ render_errors: true,
+ )
+ end
+
+ def test_include_template_with_invalid_expression
+ template = "{% include foo=>bar %}"
+
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_include_with_invalid_expression
+ template = '{% include "snippet" with foo=>bar %}'
+
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_include_attribute_with_invalid_expression
+ template = '{% include "snippet", key: foo=>bar %}'
+
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_include_for_loop_true_with_for_keyword
+ with_error_modes(:lax, :strict, :strict2) do
+ template = Template.parse("{% include 'product' for products %}")
+ include_node = template.root.nodelist.first
+
+ assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' keyword")
+ end
+ end
+
+ def test_include_for_loop_false_with_with_keyword
+ with_error_modes(:lax, :strict, :strict2) do
+ template = Template.parse("{% include 'product' with product %}")
+ include_node = template.root.nodelist.first
+
+ refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' keyword")
+ end
+ end
+
+ def test_include_for_loop_false_without_keyword
+ with_error_modes(:lax, :strict, :strict2) do
+ template = Template.parse("{% include 'header' %}")
+ include_node = template.root.nodelist.first
+
+ refute(include_node.for_loop?, "Expected for_loop? to be false when no keyword")
+ end
+ end
+
+ def test_include_for_loop_with_alias
+ with_error_modes(:lax, :strict, :strict2) do
+ template = Template.parse("{% include 'product' for products as item %}")
+ include_node = template.root.nodelist.first
+
+ assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' with alias")
+ end
+ end
+
+ def test_include_with_keyword_and_alias
+ with_error_modes(:lax, :strict, :strict2) do
+ template = Template.parse("{% include 'product' with products[0] as item %}")
+ include_node = template.root.nodelist.first
+
+ refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' with alias")
+ end
end
end # IncludeTagTest
diff --git a/test/integration/tags/increment_tag_test.rb b/test/integration/tags/increment_tag_test.rb
index 7ad5c3db1..bb7245ed5 100644
--- a/test/integration/tags/increment_tag_test.rb
+++ b/test/integration/tags/increment_tag_test.rb
@@ -6,20 +6,71 @@ class IncrementTagTest < Minitest::Test
include Liquid
def test_inc
- assert_template_result('0', '{%increment port %}', {})
- assert_template_result('0 1', '{%increment port %} {%increment port%}', {})
- assert_template_result('0 0 1 2 1',
+ assert_template_result('0 1', '{%increment port %} {{ port }}')
+ assert_template_result(' 0 1 2', '{{port}} {%increment port %} {%increment port%} {{port}}')
+ assert_template_result(
+ '0 0 1 2 1',
'{%increment port %} {%increment starboard%} ' \
'{%increment port %} {%increment port%} ' \
- '{%increment starboard %}', {})
+ '{%increment starboard %}',
+ )
end
def test_dec
- assert_template_result('9', '{%decrement port %}', { 'port' => 10 })
- assert_template_result('-1 -2', '{%decrement port %} {%decrement port%}', {})
- assert_template_result('1 5 2 2 5',
+ assert_template_result('-1 -1', '{%decrement port %} {{ port }}', { 'port' => 10 })
+ assert_template_result(' -1 -2 -2', '{{port}} {%decrement port %} {%decrement port%} {{port}}')
+ assert_template_result(
+ '0 1 2 0 3 1 1 3',
+ '{%increment starboard %} {%increment starboard%} {%increment starboard%} ' \
'{%increment port %} {%increment starboard%} ' \
'{%increment port %} {%decrement port%} ' \
- '{%decrement starboard %}', { 'port' => 1, 'starboard' => 5 })
+ '{%decrement starboard %}',
+ )
+ end
+
+ def test_increment_strict2_rejects_invalid_variable_name
+ assert_raises(Liquid::SyntaxError) do
+ Template.parse('{% increment foo bar %}', error_mode: :strict2)
+ end
+ end
+
+ def test_increment_strict2_rejects_variable_starting_with_number
+ assert_raises(Liquid::SyntaxError) do
+ Template.parse('{% increment 11aa %}', error_mode: :strict2)
+ end
+ end
+
+ def test_increment_strict2_accepts_valid_variable_name
+ template = Template.parse('{% increment my-var %}', error_mode: :strict2)
+ assert_equal('0', template.render)
+ end
+
+ def test_decrement_strict2_rejects_invalid_variable_name
+ assert_raises(Liquid::SyntaxError) do
+ Template.parse('{% decrement foo bar %}', error_mode: :strict2)
+ end
+ end
+
+ def test_decrement_strict2_rejects_variable_starting_with_number
+ assert_raises(Liquid::SyntaxError) do
+ Template.parse('{% decrement 11aa %}', error_mode: :strict2)
+ end
+ end
+
+ def test_decrement_strict2_accepts_valid_variable_name
+ template = Template.parse('{% decrement my-var %}', error_mode: :strict2)
+ assert_equal('-1', template.render)
+ end
+
+ def test_increment_strict2_rejects_empty_variable_name
+ assert_raises(Liquid::SyntaxError) do
+ Template.parse('{% increment %}', error_mode: :strict2)
+ end
+ end
+
+ def test_decrement_strict2_rejects_empty_variable_name
+ assert_raises(Liquid::SyntaxError) do
+ Template.parse('{% decrement %}', error_mode: :strict2)
+ end
end
end
diff --git a/test/integration/tags/liquid_tag_test.rb b/test/integration/tags/liquid_tag_test.rb
index 17ce00195..c6694fa70 100644
--- a/test/integration/tags/liquid_tag_test.rb
+++ b/test/integration/tags/liquid_tag_test.rb
@@ -113,4 +113,37 @@ def test_liquid_tag_in_raw
{% raw %}{% liquid echo 'test' %}{% endraw %}
LIQUID
end
+
+ def test_nested_liquid_tags
+ assert_template_result('good', <<~LIQUID)
+ {%- liquid
+ liquid
+ if true
+ echo "good"
+ endif
+ -%}
+ LIQUID
+ end
+
+ def test_nested_liquid_tags_on_same_line
+ assert_template_result('good', <<~LIQUID)
+ {%- liquid liquid liquid echo "good" -%}
+ LIQUID
+ end
+
+ def test_nested_liquid_liquid_is_not_skipped_if_used_in_non_tag_position
+ assert_template_result('liquid', <<~LIQUID, { 'liquid' => 'liquid' })
+ {%- liquid liquid liquid echo liquid -%}
+ LIQUID
+ end
+
+ def test_next_liquid_with_unclosed_if_tag
+ assert_match_syntax_error("Liquid syntax error (line 2): 'if' tag was never closed", <<~LIQUID)
+ {%- liquid
+ liquid if true
+ echo "good"
+ endif
+ -%}
+ LIQUID
+ end
end
diff --git a/test/integration/tags/raw_tag_test.rb b/test/integration/tags/raw_tag_test.rb
index 7ac0b09c1..2ca43d3b2 100644
--- a/test/integration/tags/raw_tag_test.rb
+++ b/test/integration/tags/raw_tag_test.rb
@@ -6,12 +6,17 @@ class RawTagTest < Minitest::Test
include Liquid
def test_tag_in_raw
- assert_template_result('{% comment %} test {% endcomment %}',
- '{% raw %}{% comment %} test {% endcomment %}{% endraw %}')
+ assert_template_result(
+ '{% comment %} test {% endcomment %}',
+ '{% raw %}{% comment %} test {% endcomment %}{% endraw %}',
+ )
end
def test_output_in_raw
- assert_template_result('{{ test }}', '{% raw %}{{ test }}{% endraw %}')
+ assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <')
+ assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <")
+ assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <")
+ assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}")
end
def test_open_tag_in_raw
diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb
index 28e50d1e1..6773ad251 100644
--- a/test/integration/tags/render_tag_test.rb
+++ b/test/integration/tags/render_tag_test.rb
@@ -6,131 +6,197 @@ class RenderTagTest < Minitest::Test
include Liquid
def test_render_with_no_arguments
- Liquid::Template.file_system = StubFileSystem.new('source' => 'rendered content')
- assert_template_result('rendered content', '{% render "source" %}')
+ assert_template_result(
+ 'rendered content',
+ '{% render "source" %}',
+ partials: { 'source' => 'rendered content' },
+ )
end
def test_render_tag_looks_for_file_system_in_registers_first
- file_system = StubFileSystem.new('pick_a_source' => 'from register file system')
- assert_equal('from register file system',
- Template.parse('{% render "pick_a_source" %}').render!({}, registers: { file_system: file_system }))
+ assert_template_result(
+ 'from register file system',
+ '{% render "pick_a_source" %}',
+ partials: { 'pick_a_source' => 'from register file system' },
+ )
end
def test_render_passes_named_arguments_into_inner_scope
- Liquid::Template.file_system = StubFileSystem.new('product' => '{{ inner_product.title }}')
- assert_template_result('My Product', '{% render "product", inner_product: outer_product %}',
- { 'outer_product' => { 'title' => 'My Product' } })
+ assert_template_result(
+ 'My Product',
+ '{% render "product", inner_product: outer_product %}',
+ { 'outer_product' => { 'title' => 'My Product' } },
+ partials: { 'product' => '{{ inner_product.title }}' },
+ )
end
def test_render_accepts_literals_as_arguments
- Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ price }}')
- assert_template_result('123', '{% render "snippet", price: 123 %}')
+ assert_template_result(
+ '123',
+ '{% render "snippet", price: 123 %}',
+ partials: { 'snippet' => '{{ price }}' },
+ )
end
def test_render_accepts_multiple_named_arguments
- Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ one }} {{ two }}')
- assert_template_result('1 2', '{% render "snippet", one: 1, two: 2 %}')
+ assert_template_result(
+ '1 2',
+ '{% render "snippet", one: 1, two: 2 %}',
+ partials: { 'snippet' => '{{ one }} {{ two }}' },
+ )
end
def test_render_does_not_inherit_parent_scope_variables
- Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ outer_variable }}')
- assert_template_result('', '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}')
+ assert_template_result(
+ '',
+ '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}',
+ partials: { 'snippet' => '{{ outer_variable }}' },
+ )
end
def test_render_does_not_inherit_variable_with_same_name_as_snippet
- Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ snippet }}')
- assert_template_result('', "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}")
+ assert_template_result(
+ '',
+ "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}",
+ partials: { 'snippet' => '{{ snippet }}' },
+ )
end
def test_render_does_not_mutate_parent_scope
- Liquid::Template.file_system = StubFileSystem.new('snippet' => '{% assign inner = 1 %}')
- assert_template_result('', "{% render 'snippet' %}{{ inner }}")
+ assert_template_result(
+ '',
+ "{% render 'snippet' %}{{ inner }}",
+ partials: { 'snippet' => '{% assign inner = 1 %}' },
+ )
end
def test_nested_render_tag
- Liquid::Template.file_system = StubFileSystem.new(
- 'one' => "one {% render 'two' %}",
- 'two' => 'two'
+ assert_template_result(
+ 'one two',
+ "{% render 'one' %}",
+ partials: {
+ 'one' => "one {% render 'two' %}",
+ 'two' => 'two',
+ },
)
- assert_template_result('one two', "{% render 'one' %}")
end
def test_recursively_rendered_template_does_not_produce_endless_loop
- Liquid::Template.file_system = StubFileSystem.new('loop' => '{% render "loop" %}')
+ env = Liquid::Environment.build(
+ file_system: StubFileSystem.new('loop' => '{% render "loop" %}'),
+ )
assert_raises(Liquid::StackLevelError) do
- Template.parse('{% render "loop" %}').render!
+ Template.parse('{% render "loop" %}', environment: env).render!
end
end
def test_sub_contexts_count_towards_the_same_recursion_limit
- Liquid::Template.file_system = StubFileSystem.new(
- 'loop_render' => '{% render "loop_render" %}',
+ env = Liquid::Environment.build(
+ file_system: StubFileSystem.new('loop_render' => '{% render "loop_render" %}'),
)
+
assert_raises(Liquid::StackLevelError) do
- Template.parse('{% render "loop_render" %}').render!
+ Template.parse('{% render "loop_render" %}', environment: env).render!
end
end
def test_dynamically_choosen_templates_are_not_allowed
- Liquid::Template.file_system = StubFileSystem.new('snippet' => 'should not be rendered')
+ assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
+ end
- assert_raises(Liquid::SyntaxError) do
- Liquid::Template.parse("{% assign name = 'snippet' %}{% render name %}")
+ def test_strict2_parsing_errors
+ with_error_modes(:lax, :strict) do
+ assert_template_result(
+ 'hello value1 value2',
+ '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
+ partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
+ )
+ end
+
+ with_error_modes(:strict2) do
+ assert_syntax_error(
+ '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
+ )
+ assert_syntax_error(
+ '{% render "snippet" | filter %}',
+ )
end
end
- def test_include_tag_caches_second_read_of_same_partial
+ def test_optional_commas
+ partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
+ assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
+ assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
+ assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
+ end
+
+ def test_render_tag_caches_second_read_of_same_partial
file_system = StubFileSystem.new('snippet' => 'echo')
- assert_equal('echoecho',
+ assert_equal(
+ 'echoecho',
Template.parse('{% render "snippet" %}{% render "snippet" %}')
- .render!({}, registers: { file_system: file_system }))
+ .render!({}, registers: { file_system: file_system }),
+ )
assert_equal(1, file_system.file_read_count)
end
def test_render_tag_doesnt_cache_partials_across_renders
file_system = StubFileSystem.new('snippet' => 'my message')
- assert_equal('my message',
- Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }))
+ assert_equal(
+ 'my message',
+ Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }),
+ )
assert_equal(1, file_system.file_read_count)
- assert_equal('my message',
- Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }))
+ assert_equal(
+ 'my message',
+ Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }),
+ )
assert_equal(2, file_system.file_read_count)
end
def test_render_tag_within_if_statement
- Liquid::Template.file_system = StubFileSystem.new('snippet' => 'my message')
- assert_template_result('my message', '{% if true %}{% render "snippet" %}{% endif %}')
+ assert_template_result(
+ 'my message',
+ '{% if true %}{% render "snippet" %}{% endif %}',
+ partials: { 'snippet' => 'my message' },
+ )
end
def test_break_through_render
- Liquid::Template.file_system = StubFileSystem.new('break' => '{% break %}')
- assert_template_result('1', '{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}')
- assert_template_result('112233', '{% for i in (1..3) %}{{ i }}{% render "break" %}{{ i }}{% endfor %}')
+ options = { partials: { 'break' => '{% break %}' } }
+ assert_template_result('1', '{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}', **options)
+ assert_template_result('112233', '{% for i in (1..3) %}{{ i }}{% render "break" %}{{ i }}{% endfor %}', **options)
end
def test_increment_is_isolated_between_renders
- Liquid::Template.file_system = StubFileSystem.new('incr' => '{% increment %}')
- assert_template_result('010', '{% increment %}{% increment %}{% render "incr" %}')
+ assert_template_result(
+ '010',
+ '{% increment port %}{% increment port %}{% render "incr" %}',
+ partials: { 'incr' => '{% increment port %}' },
+ )
end
def test_decrement_is_isolated_between_renders
- Liquid::Template.file_system = StubFileSystem.new('decr' => '{% decrement %}')
- assert_template_result('-1-2-1', '{% decrement %}{% decrement %}{% render "decr" %}')
+ assert_template_result(
+ '-1-2-1',
+ '{% decrement port %}{% decrement port %}{% render "decr" %}',
+ partials: { 'decr' => '{% decrement port %}' },
+ )
end
def test_includes_will_not_render_inside_render_tag
- Liquid::Template.file_system = StubFileSystem.new(
- 'foo' => 'bar',
- 'test_include' => '{% include "foo" %}'
+ assert_template_result(
+ 'Liquid error (test_include line 1): include usage is not allowed in this context',
+ '{% render "test_include" %}',
+ render_errors: true,
+ partials: {
+ 'foo' => 'bar',
+ 'test_include' => '{% include "foo" %}',
+ },
)
-
- exc = assert_raises(Liquid::DisabledError) do
- Liquid::Template.parse('{% render "test_include" %}').render!
- end
- assert_equal('Liquid error: include usage is not allowed in this context', exc.message)
end
def test_includes_will_not_render_inside_nested_sibling_tags
@@ -143,79 +209,135 @@ def test_includes_will_not_render_inside_nested_sibling_tags
'nested_render_with_sibling_include' => '{% render "test_include" %}{% include "foo" %}',
'test_include' => '{% include "foo" %}',
},
- render_errors: true
+ render_errors: true,
)
end
def test_render_tag_with
- Liquid::Template.file_system = StubFileSystem.new(
- 'product' => "Product: {{ product.title }} ",
- 'product_alias' => "Product: {{ product.title }} ",
- )
-
- assert_template_result("Product: Draft 151cm ",
+ assert_template_result(
+ "Product: Draft 151cm ",
"{% render 'product' with products[0] %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: {
+ 'product' => "Product: {{ product.title }} ",
+ 'product_alias' => "Product: {{ product.title }} ",
+ },
+ )
end
def test_render_tag_with_alias
- Liquid::Template.file_system = StubFileSystem.new(
- 'product' => "Product: {{ product.title }} ",
- 'product_alias' => "Product: {{ product.title }} ",
- )
-
- assert_template_result("Product: Draft 151cm ",
+ assert_template_result(
+ "Product: Draft 151cm ",
"{% render 'product_alias' with products[0] as product %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: {
+ 'product' => "Product: {{ product.title }} ",
+ 'product_alias' => "Product: {{ product.title }} ",
+ },
+ )
end
def test_render_tag_for_alias
- Liquid::Template.file_system = StubFileSystem.new(
- 'product' => "Product: {{ product.title }} ",
- 'product_alias' => "Product: {{ product.title }} ",
- )
-
- assert_template_result("Product: Draft 151cm Product: Element 155cm ",
+ assert_template_result(
+ "Product: Draft 151cm Product: Element 155cm ",
"{% render 'product_alias' for products as product %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: {
+ 'product' => "Product: {{ product.title }} ",
+ 'product_alias' => "Product: {{ product.title }} ",
+ },
+ )
end
def test_render_tag_for
- Liquid::Template.file_system = StubFileSystem.new(
- 'product' => "Product: {{ product.title }} ",
- 'product_alias' => "Product: {{ product.title }} ",
- )
-
- assert_template_result("Product: Draft 151cm Product: Element 155cm ",
+ assert_template_result(
+ "Product: Draft 151cm Product: Element 155cm ",
"{% render 'product' for products %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: {
+ 'product' => "Product: {{ product.title }} ",
+ 'product_alias' => "Product: {{ product.title }} ",
+ },
+ )
end
def test_render_tag_forloop
- Liquid::Template.file_system = StubFileSystem.new(
- 'product' => "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ",
- )
-
- assert_template_result("Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ",
+ assert_template_result(
+ "Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ",
"{% render 'product' for products %}",
- { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] })
+ { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] },
+ partials: {
+ 'product' => "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ",
+ },
+ )
end
def test_render_tag_for_drop
- Liquid::Template.file_system = StubFileSystem.new(
- 'loop' => "{{ value.foo }}",
+ assert_template_result(
+ "123",
+ "{% render 'loop' for loop as value %}",
+ { "loop" => TestEnumerable.new },
+ partials: {
+ 'loop' => "{{ value.foo }}",
+ },
)
-
- assert_template_result("123",
- "{% render 'loop' for loop as value %}", { "loop" => TestEnumerable.new })
end
def test_render_tag_with_drop
- Liquid::Template.file_system = StubFileSystem.new(
- 'loop' => "{{ value }}",
+ assert_template_result(
+ "TestEnumerable",
+ "{% render 'loop' with loop as value %}",
+ { "loop" => TestEnumerable.new },
+ partials: {
+ 'loop' => "{{ value }}",
+ },
+ )
+ end
+
+ def test_render_tag_renders_error_with_template_name
+ assert_template_result(
+ 'Liquid error (foo line 1): standard error',
+ "{% render 'foo' with errors %}",
+ { 'errors' => ErrorDrop.new },
+ partials: { 'foo' => '{{ foo.standard_error }}' },
+ render_errors: true,
+ )
+ end
+
+ def test_render_tag_renders_error_with_template_name_from_template_factory
+ assert_template_result(
+ 'Liquid error (some/path/foo line 1): standard error',
+ "{% render 'foo' with errors %}",
+ { 'errors' => ErrorDrop.new },
+ partials: { 'foo' => '{{ foo.standard_error }}' },
+ template_factory: StubTemplateFactory.new,
+ render_errors: true,
)
+ end
+
+ def test_render_with_invalid_expression
+ template = '{% render "snippet" with foo=>bar %}'
+
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_render_attribute_with_invalid_expression
+ template = '{% render "snippet", key: foo=>bar %}'
- assert_template_result("TestEnumerable",
- "{% render 'loop' with loop as value %}", { "loop" => TestEnumerable.new })
+ with_error_modes(:lax, :strict) do
+ refute_nil(Template.parse(template))
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
end
end
diff --git a/test/integration/tags/standard_tag_test.rb b/test/integration/tags/standard_tag_test.rb
index 496a7c46c..67471e735 100644
--- a/test/integration/tags/standard_tag_test.rb
+++ b/test/integration/tags/standard_tag_test.rb
@@ -6,8 +6,10 @@ class StandardTagTest < Minitest::Test
include Liquid
def test_no_transform
- assert_template_result('this text should come out of the template without change...',
- 'this text should come out of the template without change...')
+ assert_template_result(
+ 'this text should come out of the template without change...',
+ 'this text should come out of the template without change...',
+ )
assert_template_result('blah', 'blah')
assert_template_result('', '')
@@ -20,8 +22,10 @@ def test_no_transform
end
def test_has_a_block_which_does_nothing
- assert_template_result(%(the comment block should be removed .. right?),
- %(the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?))
+ assert_template_result(
+ %(the comment block should be removed .. right?),
+ %(the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?),
+ )
assert_template_result('', '{%comment%}{%endcomment%}')
assert_template_result('', '{%comment%}{% endcomment %}')
@@ -36,6 +40,8 @@ def test_has_a_block_which_does_nothing
assert_template_result('', '{%comment%}{% endif %}{%endcomment%}')
assert_template_result('', '{% comment %}{% endwhatever %}{% endcomment %}')
assert_template_result('', '{% comment %}{% raw %} {{%%%%}} }} { {% endcomment %} {% comment {% endraw %} {% endcomment %}')
+ assert_template_result('', '{% comment %}{% " %}{% endcomment %}')
+ assert_template_result('', '{% comment %}{%%}{% endcomment %}')
assert_template_result('foobar', 'foo{%comment%}comment{%endcomment%}bar')
assert_template_result('foobar', 'foo{% comment %}comment{% endcomment %}bar')
@@ -62,61 +68,81 @@ def test_assign_with_colon_and_spaces
def test_capture
assigns = { 'var' => 'content' }
- assert_template_result('content foo content foo ',
+ assert_template_result(
+ 'content foo content foo ',
'{{ var2 }}{% capture var2 %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}',
- assigns)
+ assigns,
+ )
end
def test_capture_detects_bad_syntax
assert_raises(SyntaxError) do
- assert_template_result('content foo content foo ',
+ assert_template_result(
+ 'content foo content foo ',
'{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}',
- { 'var' => 'content' })
+ { 'var' => 'content' },
+ )
end
end
def test_case
assigns = { 'condition' => 2 }
- assert_template_result(' its 2 ',
+ assert_template_result(
+ ' its 2 ',
'{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}',
- assigns)
+ assigns,
+ )
assigns = { 'condition' => 1 }
- assert_template_result(' its 1 ',
+ assert_template_result(
+ ' its 1 ',
'{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}',
- assigns)
+ assigns,
+ )
assigns = { 'condition' => 3 }
- assert_template_result('',
+ assert_template_result(
+ '',
'{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}',
- assigns)
+ assigns,
+ )
assigns = { 'condition' => "string here" }
- assert_template_result(' hit ',
+ assert_template_result(
+ ' hit ',
'{% case condition %}{% when "string here" %} hit {% endcase %}',
- assigns)
+ assigns,
+ )
assigns = { 'condition' => "bad string here" }
- assert_template_result('',
- '{% case condition %}{% when "string here" %} hit {% endcase %}',\
- assigns)
+ assert_template_result(
+ '',
+ '{% case condition %}{% when "string here" %} hit {% endcase %}',
+ assigns,
+ )
end
def test_case_with_else
assigns = { 'condition' => 5 }
- assert_template_result(' hit ',
+ assert_template_result(
+ ' hit ',
'{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}',
- assigns)
+ assigns,
+ )
assigns = { 'condition' => 6 }
- assert_template_result(' else ',
+ assert_template_result(
+ ' else ',
'{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}',
- assigns)
+ assigns,
+ )
assigns = { 'condition' => 6 }
- assert_template_result(' else ',
+ assert_template_result(
+ ' else ',
'{% case condition %} {% when 5 %} hit {% else %} else {% endcase %}',
- assigns)
+ assigns,
+ )
end
def test_case_on_size
@@ -129,47 +155,67 @@ def test_case_on_size
end
def test_case_on_size_with_else
- assert_template_result('else',
+ assert_template_result(
+ 'else',
'{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}',
- { 'a' => [] })
+ { 'a' => [] },
+ )
- assert_template_result('1',
+ assert_template_result(
+ '1',
'{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}',
- { 'a' => [1] })
+ { 'a' => [1] },
+ )
- assert_template_result('2',
+ assert_template_result(
+ '2',
'{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}',
- { 'a' => [1, 1] })
+ { 'a' => [1, 1] },
+ )
- assert_template_result('else',
+ assert_template_result(
+ 'else',
'{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}',
- { 'a' => [1, 1, 1] })
+ { 'a' => [1, 1, 1] },
+ )
- assert_template_result('else',
+ assert_template_result(
+ 'else',
'{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}',
- { 'a' => [1, 1, 1, 1] })
+ { 'a' => [1, 1, 1, 1] },
+ )
- assert_template_result('else',
+ assert_template_result(
+ 'else',
'{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}',
- { 'a' => [1, 1, 1, 1, 1] })
+ { 'a' => [1, 1, 1, 1, 1] },
+ )
end
def test_case_on_length_with_else
- assert_template_result('else',
+ assert_template_result(
+ 'else',
'{% case a.empty? %}{% when true %}true{% when false %}false{% else %}else{% endcase %}',
- {})
+ {},
+ )
- assert_template_result('false',
+ assert_template_result(
+ 'false',
'{% case false %}{% when true %}true{% when false %}false{% else %}else{% endcase %}',
- {})
+ {},
+ )
- assert_template_result('true',
+ assert_template_result(
+ 'true',
'{% case true %}{% when true %}true{% when false %}false{% else %}else{% endcase %}',
- {})
+ {},
+ )
- assert_template_result('else',
+ assert_template_result(
+ 'else',
'{% case NULL %}{% when true %}true{% when false %}false{% else %}else{% endcase %}',
- {})
+ {},
+ )
end
def test_assign_from_case
@@ -251,24 +297,33 @@ def test_cycle
assert_template_result('one two one', '{%cycle "one", "two"%} {%cycle "one", "two"%} {%cycle "one", "two"%}')
- assert_template_result('text-align: left text-align: right',
- '{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}')
+ assert_template_result(
+ 'text-align: left text-align: right',
+ '{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}',
+ )
end
def test_multiple_cycles
- assert_template_result('1 2 1 1 2 3 1',
- '{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}')
+ assert_template_result(
+ '1 2 1 1 2 3 1',
+ '{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}',
+ )
end
def test_multiple_named_cycles
- assert_template_result('one one two two one one',
- '{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}')
+ assert_template_result(
+ 'one one two two one one',
+ '{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}',
+ )
end
def test_multiple_named_cycles_with_names_from_context
assigns = { "var1" => 1, "var2" => 2 }
- assert_template_result('one one two two one one',
- '{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', assigns)
+ assert_template_result(
+ 'one one two two one one',
+ '{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}',
+ assigns,
+ )
end
def test_size_of_array
diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb
index a5ecf1406..2e831f416 100644
--- a/test/integration/tags/table_row_test.rb
+++ b/test/integration/tags/table_row_test.rb
@@ -18,51 +18,541 @@ def each(&block)
end
def test_table_row
- assert_template_result("\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
+ assert_template_result(
+ "\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
'{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}',
- { 'numbers' => [1, 2, 3, 4, 5, 6] })
+ { 'numbers' => [1, 2, 3, 4, 5, 6] },
+ )
- assert_template_result("\n
\n",
+ assert_template_result(
+ "\n
\n",
'{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}',
- { 'numbers' => [] })
+ { 'numbers' => [] },
+ )
end
def test_table_row_with_different_cols
- assert_template_result("\n| 1 | 2 | 3 | 4 | 5 |
\n| 6 |
\n",
+ assert_template_result(
+ "\n| 1 | 2 | 3 | 4 | 5 |
\n| 6 |
\n",
'{% tablerow n in numbers cols:5%} {{n}} {% endtablerow %}',
- { 'numbers' => [1, 2, 3, 4, 5, 6] })
+ { 'numbers' => [1, 2, 3, 4, 5, 6] },
+ )
end
def test_table_col_counter
- assert_template_result("\n| 1 | 2 |
\n| 1 | 2 |
\n| 1 | 2 |
\n",
+ assert_template_result(
+ "\n| 1 | 2 |
\n| 1 | 2 |
\n| 1 | 2 |
\n",
'{% tablerow n in numbers cols:2%}{{tablerowloop.col}}{% endtablerow %}',
- { 'numbers' => [1, 2, 3, 4, 5, 6] })
+ { 'numbers' => [1, 2, 3, 4, 5, 6] },
+ )
end
def test_quoted_fragment
- assert_template_result("\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
+ assert_template_result(
+ "\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
"{% tablerow n in collections.frontpage cols:3%} {{n}} {% endtablerow %}",
- { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } })
- assert_template_result("\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
+ { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } },
+ )
+ assert_template_result(
+ "\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
"{% tablerow n in collections['frontpage'] cols:3%} {{n}} {% endtablerow %}",
- { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } })
+ { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } },
+ )
end
def test_enumerable_drop
- assert_template_result("\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
+ assert_template_result(
+ "\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
'{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}',
- { 'numbers' => ArrayDrop.new([1, 2, 3, 4, 5, 6]) })
+ { 'numbers' => ArrayDrop.new([1, 2, 3, 4, 5, 6]) },
+ )
end
def test_offset_and_limit
- assert_template_result("\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
+ assert_template_result(
+ "\n| 1 | 2 | 3 |
\n| 4 | 5 | 6 |
\n",
'{% tablerow n in numbers cols:3 offset:1 limit:6%} {{n}} {% endtablerow %}',
- { 'numbers' => [0, 1, 2, 3, 4, 5, 6, 7] })
+ { 'numbers' => [0, 1, 2, 3, 4, 5, 6, 7] },
+ )
end
def test_blank_string_not_iterable
- assert_template_result("\n
\n",
+ assert_template_result(
+ "\n
\n",
"{% tablerow char in characters cols:3 %}I WILL NOT BE OUTPUT{% endtablerow %}",
- { 'characters' => '' })
+ { 'characters' => '' },
+ )
+ end
+
+ def test_cols_nil_constant_same_as_evaluated_nil_expression
+ expect = "\n" \
+ "| false | " \
+ "false | " \
+ "
\n"
+
+ assert_template_result(
+ expect,
+ "{% tablerow i in (1..2) cols:nil %}{{ tablerowloop.col_last }}{% endtablerow %}",
+ )
+
+ assert_template_result(
+ expect,
+ "{% tablerow i in (1..2) cols:var %}{{ tablerowloop.col_last }}{% endtablerow %}",
+ { "var" => nil },
+ )
+ end
+
+ def test_nil_limit_is_treated_as_zero
+ expect = "\n" \
+ "
\n"
+
+ assert_template_result(
+ expect,
+ "{% tablerow i in (1..2) limit:nil %}{{ i }}{% endtablerow %}",
+ )
+
+ assert_template_result(
+ expect,
+ "{% tablerow i in (1..2) limit:var %}{{ i }}{% endtablerow %}",
+ { "var" => nil },
+ )
+ end
+
+ def test_nil_offset_is_treated_as_zero
+ expect = "\n" \
+ "| 1:false | " \
+ "2:true | " \
+ "
\n"
+
+ assert_template_result(
+ expect,
+ "{% tablerow i in (1..2) offset:nil %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}",
+ )
+
+ assert_template_result(
+ expect,
+ "{% tablerow i in (1..2) offset:var %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}",
+ { "var" => nil },
+ )
+ end
+
+ def test_tablerow_loop_drop_attributes
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..2) %}
+ col: {{ tablerowloop.col }}
+ col0: {{ tablerowloop.col0 }}
+ col_first: {{ tablerowloop.col_first }}
+ col_last: {{ tablerowloop.col_last }}
+ first: {{ tablerowloop.first }}
+ index: {{ tablerowloop.index }}
+ index0: {{ tablerowloop.index0 }}
+ last: {{ tablerowloop.last }}
+ length: {{ tablerowloop.length }}
+ rindex: {{ tablerowloop.rindex }}
+ rindex0: {{ tablerowloop.rindex0 }}
+ row: {{ tablerowloop.row }}
+ {% endtablerow %}
+ LIQUID
+
+ expected_output = <<~OUTPUT
+
+ |
+ col: 1
+ col0: 0
+ col_first: true
+ col_last: false
+ first: true
+ index: 1
+ index0: 0
+ last: false
+ length: 2
+ rindex: 2
+ rindex0: 1
+ row: 1
+ |
+ col: 2
+ col0: 1
+ col_first: false
+ col_last: true
+ first: false
+ index: 2
+ index0: 1
+ last: true
+ length: 2
+ rindex: 1
+ rindex0: 0
+ row: 1
+ |
+ OUTPUT
+
+ assert_template_result(expected_output, template)
+ end
+
+ def test_table_row_renders_correct_error_message_for_invalid_parameters
+ assert_template_result(
+ "Liquid error (line 1): invalid integer",
+ '{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
+ error_mode: :warn,
+ render_errors: true,
+ )
+
+ assert_template_result(
+ "Liquid error (line 1): invalid integer",
+ '{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
+ error_mode: :warn,
+ render_errors: true,
+ )
+
+ assert_template_result(
+ "Liquid error (line 1): invalid integer",
+ '{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
+ render_errors: true,
+ error_mode: :warn,
+ )
+ end
+
+ def test_table_row_handles_interrupts
+ assert_template_result(
+ "\n| 1 |
\n",
+ '{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
+ )
+
+ assert_template_result(
+ "\n| 1 | 2 |
\n| 3 |
\n",
+ '{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
+ )
+ end
+
+ def test_table_row_does_not_leak_interrupts
+ template = <<~LIQUID
+ {% for i in (1..2) -%}
+ {% for j in (1..2) -%}
+ {% tablerow k in (1..3) %}{% break %}{% endtablerow -%}
+ loop j={{ j }}
+ {% endfor -%}
+ loop i={{ i }}
+ {% endfor -%}
+ after loop
+ LIQUID
+
+ expected = <<~STR
+
+ |
+ loop j=1
+
+ |
+ loop j=2
+ loop i=1
+
+ |
+ loop j=1
+
+ |
+ loop j=2
+ loop i=2
+ after loop
+ STR
+
+ assert_template_result(
+ expected,
+ template,
+ )
+ end
+
+ def test_tablerow_with_cols_attribute_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 | 3 |
+ | 4 | 5 | 6 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template)
+ end
+ end
+
+ def test_tablerow_with_limit_attribute_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 | 3 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template)
+ end
+ end
+
+ def test_tablerow_with_offset_attribute_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 3 | 4 | 5 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template)
+ end
+ end
+
+ def test_tablerow_with_range_attribute_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 | 3 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template)
+ end
+ end
+
+ def test_tablerow_with_multiple_attributes_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 2 | 3 |
+ | 4 | 5 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template)
+ end
+ end
+
+ def test_tablerow_with_variable_collection_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 |
+ | 3 | 4 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
+ end
+ end
+
+ def test_tablerow_with_dotted_access_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 |
+ | 3 | 4 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
+ end
+ end
+
+ def test_tablerow_with_bracketed_access_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 10 | 20 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
+ end
+ end
+
+ def test_tablerow_without_attributes_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 | 3 |
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template)
+ end
+ end
+
+ def test_tablerow_without_in_keyword_in_strict2_mode
+ template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
+
+ with_error_modes(:strict2) do
+ error = assert_raises(SyntaxError) { Template.parse(template) }
+ assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
+ end
+ end
+
+ def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode
+ template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
+
+ with_error_modes(:strict2) do
+ error = assert_raises(SyntaxError) { Template.parse(template) }
+ assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
+ end
+ end
+
+ def test_tablerow_with_empty_collection_in_strict2_mode
+ template = <<~LIQUID.chomp
+ {% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
+ LIQUID
+
+ expected = <<~OUTPUT
+
+
+ OUTPUT
+
+ with_error_modes(:strict2) do
+ assert_template_result(expected, template, { 'empty_array' => [] })
+ end
+ end
+
+ def test_tablerow_with_invalid_attribute_strict_vs_strict2
+ template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
+
+ expected = <<~OUTPUT
+
+ | 1 | 2 | 3 | 4 | 5 |
+ OUTPUT
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result(expected, template)
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(SyntaxError) { Template.parse(template) }
+ assert_match(/Invalid attribute 'invalid_attr'/, error.message)
+ end
+ end
+
+ def test_tablerow_with_invalid_expression_strict_vs_strict2
+ template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
+
+ with_error_modes(:lax, :strict) do
+ expected = <<~OUTPUT
+
+
+ OUTPUT
+ assert_template_result(expected, template)
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(SyntaxError) { Template.parse(template) }
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_integer_range_is_not_materialized_and_charges_empty_iterations
+ range = bounded_integer_range_with_tripwires
+ template = Template.parse('{% tablerow i in numbers %}{% endtablerow %}')
+ template.resource_limits.render_score_limit = 3
+
+ assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
+
+ assert(template.resource_limits.reached?)
+ assert_equal(4, template.resource_limits.render_score)
+ end
+
+ def test_integer_range_uses_arithmetic_offsets_limit_and_full_metadata
+ range = bounded_integer_range_with_tripwires
+ template = Template.parse(
+ '{% tablerow i in numbers offset:997 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}',
+ )
+
+ assert_equal(
+ "\n| 2:1:998 | 2:2:999 |
\n",
+ template.render!('numbers' => range),
+ )
+ end
+
+ def test_tablerow_checks_generated_output_during_empty_body_iteration
+ template = Template.parse('{% tablerow i in (1..100) %}{% endtablerow %}')
+ template.resource_limits.render_length_limit = 40
+ checked_lengths = []
+ limits = template.resource_limits
+ original_increment_write_score = limits.method(:increment_write_score)
+ limits.define_singleton_method(:increment_write_score) do |output|
+ checked_lengths << output.bytesize
+ original_increment_write_score.call(output)
+ end
+
+ assert_equal('Liquid error: Memory limits exceeded', template.render)
+ assert(template.resource_limits.reached?)
+ assert_operator(checked_lengths.last, :<, 1000)
+ end
+
+ def test_tablerow_range_scores_persist_across_renders
+ template = Template.parse('{% tablerow i in (1..2) %}{% endtablerow %}')
+ template.resource_limits.cumulative_render_score_limit = 3
+ template.render!
+ assert_raises(Liquid::MemoryError) { template.render! }
+ end
+
+ def test_range_subclass_uses_its_custom_each_with_beginless_bounds
+ range = Class.new(Range) do
+ def each
+ yield 10
+ yield 20
+ end
+ end.new(nil, 3)
+
+ assert_template_result(
+ "\n| 10 | 20 |
\n",
+ '{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
+ { 'numbers' => range },
+ )
+ end
+
+ def test_endless_range_subclass_uses_its_custom_each_without_a_limit
+ range = Class.new(Range) do
+ def each
+ yield 10
+ yield 20
+ end
+ end.new(1, nil)
+
+ assert_template_result(
+ "\n| 10 | 20 |
\n",
+ '{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
+ { 'numbers' => range },
+ )
+ end
+
+ def test_range_subclass_custom_to_a_is_not_used
+ range = Class.new(Range) do
+ def to_a
+ [42]
+ end
+ end.new(1, 3)
+
+ assert_template_result(
+ "\n| 1 | 2 | 3 |
\n",
+ '{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
+ { 'numbers' => range },
+ )
end
end
diff --git a/test/integration/tags/unless_else_tag_test.rb b/test/integration/tags/unless_else_tag_test.rb
index 174fb7901..0bbdb2b9c 100644
--- a/test/integration/tags/unless_else_tag_test.rb
+++ b/test/integration/tags/unless_else_tag_test.rb
@@ -7,8 +7,10 @@ class UnlessElseTagTest < Minitest::Test
def test_unless
assert_template_result(' ', ' {% unless true %} this text should not go into the output {% endunless %} ')
- assert_template_result(' this text should go into the output ',
- ' {% unless false %} this text should go into the output {% endunless %} ')
+ assert_template_result(
+ ' this text should go into the output ',
+ ' {% unless false %} this text should go into the output {% endunless %} ',
+ )
assert_template_result(' you rock ?', '{% unless true %} you suck {% endunless %} {% unless false %} you rock {% endunless %}?')
end
diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb
index d3f635645..1e41be06e 100644
--- a/test/integration/template_test.rb
+++ b/test/integration/template_test.rb
@@ -93,10 +93,12 @@ def test_lambda_is_called_once_from_persistent_assigns_over_multiple_parses_and_
def test_lambda_is_called_once_from_custom_assigns_over_multiple_parses_and_renders
t = Template.new
- assigns = { 'number' => -> {
- @global ||= 0
- @global += 1
- } }
+ assigns = {
+ 'number' => -> {
+ @global ||= 0
+ @global += 1
+ },
+ }
assert_equal('1', t.parse("{{number}}").render!(assigns))
assert_equal('1', t.parse("{{number}}").render!(assigns))
assert_equal('1', t.render!(assigns))
@@ -130,8 +132,8 @@ def test_resource_limits_render_score
assert_equal("Liquid error: Memory limits exceeded", t.render)
assert(t.resource_limits.reached?)
- t.resource_limits.render_score_limit = 200
- assert_equal((" foo " * 100), t.render!)
+ t.resource_limits.render_score_limit = 201
+ assert_equal(" foo " * 100, t.render!)
refute_nil(t.resource_limits.render_score)
end
@@ -177,6 +179,86 @@ def test_render_length_uses_number_of_bytes_not_characters
assert_equal("すごい", t.render)
end
+ def test_cumulative_render_score_limit_across_render_tags
+ file_system = StubFileSystem.new(
+ 'loop' => '{% for a in (1..10) %} foo {% endfor %}',
+ )
+ environment = Liquid::Environment.build(file_system: file_system)
+
+ # Without cumulative limit, all 5 partials render successfully
+ t = Template.parse(
+ '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
+ environment: environment,
+ )
+ unlimited_output = t.render!
+ total_cumulative = t.resource_limits.cumulative_render_score
+
+ # With cumulative limit set below the total, rendering stops early
+ t2 = Template.parse(
+ '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
+ environment: environment,
+ )
+ t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
+ limited_output = t2.render
+ assert(t2.resource_limits.reached?)
+ assert_operator(limited_output.length, :<, unlimited_output.length)
+ end
+
+ def test_cumulative_render_score_limit_raises_on_render_bang
+ file_system = StubFileSystem.new(
+ 'loop' => '{% for a in (1..10) %} foo {% endfor %}',
+ )
+ environment = Liquid::Environment.build(file_system: file_system)
+ t = Template.parse(
+ '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
+ environment: environment,
+ )
+ t.resource_limits.cumulative_render_score_limit = 20
+ assert_raises(Liquid::MemoryError) do
+ t.render!
+ end
+ end
+
+ def test_cumulative_assign_score_limit_across_include_tags
+ file_system = StubFileSystem.new(
+ 'assign_partial' => '{% assign x = "a long string value here" %}',
+ )
+ environment = Liquid::Environment.build(file_system: file_system)
+
+ # Without cumulative limit, all 5 partials render
+ t = Template.parse(
+ '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
+ environment: environment,
+ )
+ t.render!
+ total_cumulative = t.resource_limits.cumulative_assign_score
+
+ # With cumulative limit set below the total, rendering stops early
+ t2 = Template.parse(
+ '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
+ environment: environment,
+ )
+ t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
+ t2.render
+ assert(t2.resource_limits.reached?)
+ end
+
+ def test_cumulative_render_score_tracks_across_partials_without_limit
+ file_system = StubFileSystem.new(
+ 'loop' => '{% for a in (1..10) %} foo {% endfor %}',
+ )
+ environment = Liquid::Environment.build(file_system: file_system)
+ t = Template.parse(
+ '{% render "loop" %}{% render "loop" %}{% render "loop" %}',
+ environment: environment,
+ )
+ t.render!
+ assert(
+ t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
+ "cumulative should exceed per-template score after multiple partials",
+ )
+ end
+
def test_default_resource_limits_unaffected_by_render_with_context
context = Context.new
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
@@ -335,4 +417,22 @@ def to_s
assert_equal("x=2", output)
assert_instance_of(String, output)
end
+
+ def test_raises_error_with_invalid_utf8
+ e = assert_raises(TemplateEncodingError) do
+ Template.parse(<<~LIQUID)
+ {% comment %}
+ \xC0
+ {% endcomment %}
+ LIQUID
+ end
+
+ assert_equal('Liquid error: Invalid template encoding', e.message)
+ end
+
+ def test_allows_non_string_values_as_source
+ assert_equal('', Template.parse(nil).render)
+ assert_equal('1', Template.parse(1).render)
+ assert_equal('true', Template.parse(true).render)
+ end
end
diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb
index c1fe16f7b..58f73a315 100644
--- a/test/integration/variable_test.rb
+++ b/test/integration/variable_test.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'test_helper'
+require 'timeout'
class VariableTest < Minitest::Test
include Liquid
@@ -24,17 +25,23 @@ def test_variable_lookup_calls_to_liquid_value
def test_if_tag_calls_to_liquid_value
assert_template_result('one', '{% if foo == 1 %}one{% endif %}', { 'foo' => IntegerDrop.new('1') })
+ assert_template_result('one', '{% if foo == eqv %}one{% endif %}', { 'foo' => IntegerDrop.new(1), 'eqv' => IntegerDrop.new(1) })
assert_template_result('one', '{% if 0 < foo %}one{% endif %}', { 'foo' => IntegerDrop.new('1') })
assert_template_result('one', '{% if foo > 0 %}one{% endif %}', { 'foo' => IntegerDrop.new('1') })
+ assert_template_result('one', '{% if b > a %}one{% endif %}', { 'b' => IntegerDrop.new(1), 'a' => IntegerDrop.new(0) })
assert_template_result('true', '{% if foo == true %}true{% endif %}', { 'foo' => BooleanDrop.new(true) })
assert_template_result('true', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(true) })
assert_template_result('', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(false) })
assert_template_result('', '{% if foo == true %}True{% endif %}', { 'foo' => BooleanDrop.new(false) })
+ assert_template_result('', '{% if foo and true %}SHOULD NOT HAPPEN{% endif %}', { 'foo' => BooleanDrop.new(false) })
+
+ assert_template_result('one', '{% if a contains x %}one{% endif %}', { 'a' => [1], 'x' => IntegerDrop.new(1) })
end
def test_unless_tag_calls_to_liquid_value
assert_template_result('', '{% unless foo %}true{% endunless %}', { 'foo' => BooleanDrop.new(true) })
+ assert_template_result('true', '{% unless foo %}true{% endunless %}', { 'foo' => BooleanDrop.new(false) })
end
def test_case_tag_calls_to_liquid_value
@@ -48,7 +55,7 @@ def test_simple_with_whitespaces
def test_expression_with_whitespace_in_square_brackets
assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } })
- assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
+ assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
end
def test_ignore_unknown
@@ -123,11 +130,148 @@ def test_render_symbol
assert_template_result('bar', '{{ foo }}', { 'foo' => :bar })
end
+ def test_nested_array
+ assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] })
+ end
+
def test_dynamic_find_var
- assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
+ assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_raw_value_variable
- assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
+ assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
+ end
+
+ def test_dynamic_find_var_with_drop
+ assert_template_result(
+ 'bar',
+ '{{ self[list[settings.zero]] }}',
+ {
+ 'list' => ['foo'],
+ 'settings' => SettingsDrop.new("zero" => 0),
+ 'foo' => 'bar',
+ },
+ )
+
+ assert_template_result(
+ 'foo',
+ '{{ self[list[settings.zero]["foo"]] }}',
+ {
+ 'list' => [{ 'foo' => 'bar' }],
+ 'settings' => SettingsDrop.new("zero" => 0),
+ 'bar' => 'foo',
+ },
+ )
+ end
+
+ def test_double_nested_variable_lookup
+ assert_template_result(
+ 'bar',
+ '{{ list[list[settings.zero]]["foo"] }}',
+ {
+ 'list' => [1, { 'foo' => 'bar' }],
+ 'settings' => SettingsDrop.new("zero" => 0),
+ 'bar' => 'foo',
+ },
+ )
+ end
+
+ def test_variable_lookup_should_not_hang_with_invalid_syntax
+ Timeout.timeout(1) do
+ assert_template_result(
+ 'bar',
+ "{{['foo'}}",
+ {
+ 'foo' => 'bar',
+ },
+ error_mode: :lax,
+ )
+ end
+
+ very_long_key = "1234567890" * 100
+
+ template_list = [
+ "{{['#{very_long_key}']}}", # valid
+ "{{['#{very_long_key}'}}", # missing closing bracket
+ "{{[['#{very_long_key}']}}", # extra open bracket
+ ]
+
+ template_list.each do |template|
+ Timeout.timeout(1) do
+ assert_template_result(
+ 'bar',
+ template,
+ {
+ very_long_key => 'bar',
+ },
+ error_mode: :lax,
+ )
+ end
+ end
+ end
+
+ def test_filter_with_single_trailing_comma
+ template = '{{ "hello" | append: "world", }}'
+
+ with_error_modes(:strict) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/is not a valid expression/, error.message)
+ end
+
+ with_error_modes(:strict2) do
+ assert_template_result('helloworld', template)
+ end
+ end
+
+ def test_multiple_filters_with_trailing_commas
+ template = '{{ "hello" | append: "1", | append: "2", }}'
+
+ with_error_modes(:strict) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/is not a valid expression/, error.message)
+ end
+
+ with_error_modes(:strict2) do
+ assert_template_result('hello12', template)
+ end
+ end
+
+ def test_filter_with_colon_but_no_arguments
+ template = '{{ "test" | upcase: }}'
+
+ with_error_modes(:strict) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/is not a valid expression/, error.message)
+ end
+
+ with_error_modes(:strict2) do
+ assert_template_result('TEST', template)
+ end
+ end
+
+ def test_filter_chain_with_colon_no_args
+ template = '{{ "test" | append: "x" | upcase: }}'
+
+ with_error_modes(:strict) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/is not a valid expression/, error.message)
+ end
+
+ with_error_modes(:strict2) do
+ assert_template_result('TESTX', template)
+ end
+ end
+
+ def test_combining_trailing_comma_and_empty_args
+ template = '{{ "test" | append: "x", | upcase: }}'
+
+ with_error_modes(:strict) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+ assert_match(/is not a valid expression/, error.message)
+ end
+
+ with_error_modes(:strict2) do
+ assert_template_result('TESTX', template)
+ end
end
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index de2d534e4..326142b7f 100755
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -13,12 +13,7 @@
puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym
end
-Liquid::Template.error_mode = mode
-
-if ENV['LIQUID_C'] == '1'
- puts "-- LIQUID C"
- require 'liquid/c'
-end
+Liquid::Environment.default.error_mode = mode
if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here.
@@ -37,14 +32,25 @@ def fixture(name)
module Assertions
include Liquid
+ # Exact Range fixture for fast-path tests; singleton tripwires must remain
+ # untouched because the arithmetic path does not materialize or traverse it.
+ def bounded_integer_range_with_tripwires
+ range = (1..1000).dup
+ range.define_singleton_method(:to_a) { raise 'range was materialized' }
+ range.define_singleton_method(:each) { raise 'range was traversed' }
+ range
+ end
+
def assert_template_result(
expected, template, assigns = {},
- message: nil, partials: nil, error_mode: nil, render_errors: false
+ message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
+ template_factory: nil
)
- template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym)
- file_system = StubFileSystem.new(partials) if partials
- registers = Liquid::Registers.new(file_system: file_system)
- context = Liquid::Context.build(environments: assigns, rethrow_errors: !render_errors, registers: registers)
+ file_system = StubFileSystem.new(partials || {})
+ environment = Liquid::Environment.build(file_system: file_system)
+ template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment)
+ registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory)
+ context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment)
output = template.render(context)
assert_equal(expected, output, message)
end
@@ -56,6 +62,10 @@ def assert_match_syntax_error(match, template, error_mode: nil)
assert_match(match, exception.message)
end
+ def assert_syntax_error(template, error_mode: nil)
+ assert_match_syntax_error("", template, error_mode: error_mode)
+ end
+
def assert_usage_increment(name, times: 1)
old_method = Liquid::Usage.method(:increment)
calls = 0
@@ -73,44 +83,29 @@ def assert_usage_increment(name, times: 1)
assert_equal(times, calls, "Number of calls to Usage.increment with #{name.inspect}")
end
- def with_global_filter(*globals)
- original_global_cache = Liquid::StrainerFactory::GlobalCache
- Liquid::StrainerFactory.send(:remove_const, :GlobalCache)
- Liquid::StrainerFactory.const_set(:GlobalCache, Class.new(Liquid::StrainerTemplate))
-
- globals.each do |global|
- Liquid::Template.register_filter(global)
+ def with_global_filter(*globals, &blk)
+ environment = Liquid::Environment.build do |w|
+ w.register_filters(globals)
end
- Liquid::StrainerFactory.send(:strainer_class_cache).clear
- begin
- yield
- ensure
- Liquid::StrainerFactory.send(:remove_const, :GlobalCache)
- Liquid::StrainerFactory.const_set(:GlobalCache, original_global_cache)
- Liquid::StrainerFactory.send(:strainer_class_cache).clear
- end
- end
- def with_error_mode(mode)
- old_mode = Liquid::Template.error_mode
- Liquid::Template.error_mode = mode
- yield
- ensure
- Liquid::Template.error_mode = old_mode
+ Environment.dangerously_override(environment, &blk)
end
- def with_custom_tag(tag_name, tag_class)
- old_tag = Liquid::Template.tags[tag_name]
- begin
- Liquid::Template.register_tag(tag_name, tag_class)
+ def with_error_modes(*modes)
+ old_mode = Liquid::Environment.default.error_mode
+ modes.each do |mode|
+ Liquid::Environment.default.error_mode = mode
yield
- ensure
- if old_tag
- Liquid::Template.tags[tag_name] = old_tag
- else
- Liquid::Template.tags.delete(tag_name)
- end
end
+ ensure
+ Liquid::Environment.default.error_mode = old_mode
+ end
+
+ def with_custom_tag(tag_name, tag_class, &block)
+ environment = Liquid::Environment.default.dup
+ environment.register_tag(tag_name, tag_class)
+
+ Environment.dangerously_override(environment, &block)
end
end
end
@@ -121,16 +116,23 @@ def to_liquid
end
end
+class SettingsDrop < Liquid::Drop
+ def initialize(settings)
+ super()
+ @settings = settings
+ end
+
+ def liquid_method_missing(key)
+ @settings[key]
+ end
+end
+
class IntegerDrop < Liquid::Drop
def initialize(value)
super()
@value = value.to_i
end
- def ==(other)
- @value == other
- end
-
def to_s
@value.to_s
end
@@ -146,8 +148,21 @@ def initialize(value)
@value = value
end
- def ==(other)
- @value == other
+ def to_liquid_value
+ @value
+ end
+
+ def to_s
+ @value ? "Yay" : "Nay"
+ end
+end
+
+class StringDrop < Liquid::Drop
+ include Comparable
+
+ def initialize(value)
+ super()
+ @value = value
end
def to_liquid_value
@@ -155,7 +170,19 @@ def to_liquid_value
end
def to_s
- @value ? "Yay" : "Nay"
+ @value
+ end
+
+ def to_str
+ @value
+ end
+
+ def inspect
+ "#"
+ end
+
+ def <=>(other)
+ to_liquid_value <=> Liquid::Utils.to_liquid_value(other)
end
end
@@ -181,6 +208,26 @@ def exception
end
end
+class CustomToLiquidDrop < Liquid::Drop
+ def initialize(value)
+ @value = value
+ super()
+ end
+
+ def to_liquid
+ @value
+ end
+end
+
+class HashWithCustomToS < Hash
+ def to_s
+ "kewl"
+ end
+end
+
+class HashWithoutCustomToS < Hash
+end
+
class StubFileSystem
attr_reader :file_read_count
@@ -202,8 +249,10 @@ def initialize
@count = 0
end
- def for(_template_name)
+ def for(template_name)
@count += 1
- Liquid::Template.new
+ template = Liquid::Template.new
+ template.name = "some/path/" + template_name
+ template
end
end
diff --git a/test/unit/block_unit_test.rb b/test/unit/block_unit_test.rb
index 42093ef18..cfaf3f663 100644
--- a/test/unit/block_unit_test.rb
+++ b/test/unit/block_unit_test.rb
@@ -32,19 +32,33 @@ def test_variable_middle
assert_equal(String, template.root.nodelist[2].class)
end
+ def test_variable_with_multibyte_character
+ template = Liquid::Template.parse("{{ '❤️' }}")
+ assert_equal(1, template.root.nodelist.size)
+ assert_equal(Variable, template.root.nodelist[0].class)
+ end
+
def test_variable_many_embedded_fragments
template = Liquid::Template.parse(" {{funk}} {{so}} {{brother}} ")
assert_equal(7, template.root.nodelist.size)
- assert_equal([String, Variable, String, Variable, String, Variable, String],
- block_types(template.root.nodelist))
+ assert_equal(
+ [String, Variable, String, Variable, String, Variable, String],
+ block_types(template.root.nodelist),
+ )
end
- def test_with_block
+ def test_comment_tag_with_block
template = Liquid::Template.parse(" {% comment %} {% endcomment %} ")
assert_equal([String, Comment, String], block_types(template.root.nodelist))
assert_equal(3, template.root.nodelist.size)
end
+ def test_doc_tag_with_block
+ template = Liquid::Template.parse(" {% doc %} {% enddoc %} ")
+ assert_equal([String, Doc, String], block_types(template.root.nodelist))
+ assert_equal(3, template.root.nodelist.size)
+ end
+
private
def block_types(nodelist)
diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb
index 8fcc45103..d7f35f609 100644
--- a/test/unit/condition_unit_test.rb
+++ b/test/unit/condition_unit_test.rb
@@ -55,6 +55,11 @@ def test_contains_works_on_strings
assert_evaluates_false('bob', 'contains', '---')
end
+ def test_contains_binary_encoding_compatibility_with_utf8
+ assert_evaluates_true('🙈'.b, 'contains', '🙈')
+ assert_evaluates_true('🙈', 'contains', '🙈'.b)
+ end
+
def test_invalid_comparation_operator
assert_evaluates_argument_error(1, '~~', 0)
end
@@ -156,21 +161,222 @@ def test_default_context_is_deprecated
assert_equal(true, Condition.new(1, '==', 1).evaluate)
end
- expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
- " and will be removed from Liquid 6.0.0."
+ expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \
+ "and will be removed from Liquid 6.0.0."
assert_includes(err.lines.map(&:strip), expected)
end
+ def test_parse_expression_in_strict_mode
+ environment = Environment.build(error_mode: :strict)
+ parse_context = ParseContext.new(environment: environment)
+ result = Condition.parse_expression(parse_context, 'product.title')
+
+ assert_instance_of(VariableLookup, result)
+ assert_equal('product', result.name)
+ assert_equal(['title'], result.lookups)
+ end
+
+ def test_parse_expression_in_strict2_mode_raises_internal_error
+ environment = Environment.build(error_mode: :strict2)
+ parse_context = ParseContext.new(environment: environment)
+
+ error = assert_raises(Liquid::InternalError) do
+ Condition.parse_expression(parse_context, 'product.title')
+ end
+
+ assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
+ end
+
+ def test_parse_expression_with_safe_true_in_strict2_mode
+ environment = Environment.build(error_mode: :strict2)
+ parse_context = ParseContext.new(environment: environment)
+ result = Condition.parse_expression(parse_context, 'product.title', safe: true)
+
+ assert_instance_of(VariableLookup, result)
+ assert_equal('product', result.name)
+ assert_equal(['title'], result.lookups)
+ end
+
+ # Tests for blank? comparison without ActiveSupport
+ #
+ # Ruby's standard library does not include blank? on String, Array, Hash, etc.
+ # ActiveSupport adds blank? but Liquid must work without it. These tests verify
+ # that Liquid implements blank? semantics internally for use in templates like:
+ # {% if x == blank %}...{% endif %}
+ #
+ # The blank? semantics match ActiveSupport's behavior:
+ # - nil and false are blank
+ # - Strings are blank if empty or contain only whitespace
+ # - Arrays and Hashes are blank if empty
+ # - true and numbers are never blank
+
+ def test_blank_with_whitespace_string
+ # Template authors expect " " to be blank since it has no visible content.
+ # This matches ActiveSupport's String#blank? which returns true for whitespace-only strings.
+ @context['whitespace'] = ' '
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_true(VariableLookup.new('whitespace'), '==', blank_literal)
+ end
+
+ def test_blank_with_empty_string
+ # An empty string has no content, so it should be considered blank.
+ # This is the most basic case of a blank string.
+ @context['empty_string'] = ''
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_true(VariableLookup.new('empty_string'), '==', blank_literal)
+ end
+
+ def test_blank_with_empty_array
+ # Empty arrays have no elements, so they are blank.
+ # Useful for checking if a collection has items: {% if products == blank %}
+ @context['empty_array'] = []
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_true(VariableLookup.new('empty_array'), '==', blank_literal)
+ end
+
+ def test_blank_with_empty_hash
+ # Empty hashes have no key-value pairs, so they are blank.
+ # Useful for checking if settings/options exist: {% if settings == blank %}
+ @context['empty_hash'] = {}
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_true(VariableLookup.new('empty_hash'), '==', blank_literal)
+ end
+
+ def test_blank_with_nil
+ # nil represents "nothing" and is the canonical blank value.
+ # Unassigned variables resolve to nil, so this enables: {% if missing_var == blank %}
+ @context['nil_value'] = nil
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_true(VariableLookup.new('nil_value'), '==', blank_literal)
+ end
+
+ def test_blank_with_false
+ # false is considered blank to match ActiveSupport semantics.
+ # This allows {% if some_flag == blank %} to work when flag is false.
+ @context['false_value'] = false
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_true(VariableLookup.new('false_value'), '==', blank_literal)
+ end
+
+ def test_not_blank_with_true
+ # true is a definite value, not blank.
+ # Ensures {% if flag == blank %} works correctly for boolean flags.
+ @context['true_value'] = true
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_false(VariableLookup.new('true_value'), '==', blank_literal)
+ end
+
+ def test_not_blank_with_number
+ # Numbers (including zero) are never blank - they represent actual values.
+ # 0 is a valid quantity, not the absence of a value.
+ @context['number'] = 42
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_false(VariableLookup.new('number'), '==', blank_literal)
+ end
+
+ def test_not_blank_with_string_content
+ # A string with actual content is not blank.
+ # This is the expected behavior for most template string comparisons.
+ @context['string'] = 'hello'
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_false(VariableLookup.new('string'), '==', blank_literal)
+ end
+
+ def test_not_blank_with_non_empty_array
+ # An array with elements has content, so it's not blank.
+ # Enables patterns like {% unless products == blank %}Show products{% endunless %}
+ @context['array'] = [1, 2, 3]
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_false(VariableLookup.new('array'), '==', blank_literal)
+ end
+
+ def test_not_blank_with_non_empty_hash
+ # A hash with key-value pairs has content, so it's not blank.
+ # Useful for checking if configuration exists: {% if config != blank %}
+ @context['hash'] = { 'a' => 1 }
+ blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
+
+ assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal)
+ end
+
+ # Tests for empty? comparison without ActiveSupport
+ #
+ # empty? is distinct from blank? - it only checks if a collection has zero elements.
+ # For strings, empty? checks length == 0, NOT whitespace content.
+ # Ruby's standard library has empty? on String, Array, and Hash, but Liquid
+ # provides a fallback implementation for consistency.
+
+ def test_empty_with_empty_string
+ # An empty string ("") has length 0, so it's empty.
+ # Different from blank - empty is a stricter check.
+ @context['empty_string'] = ''
+ empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
+
+ assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal)
+ end
+
+ def test_empty_with_whitespace_string_not_empty
+ # Whitespace strings have length > 0, so they are NOT empty.
+ # This is the key difference between empty and blank:
+ # " ".empty? => false, but " ".blank? => true
+ @context['whitespace'] = ' '
+ empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
+
+ assert_evaluates_false(VariableLookup.new('whitespace'), '==', empty_literal)
+ end
+
+ def test_empty_with_empty_array
+ # An array with no elements is empty.
+ # [].empty? => true
+ @context['empty_array'] = []
+ empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
+
+ assert_evaluates_true(VariableLookup.new('empty_array'), '==', empty_literal)
+ end
+
+ def test_empty_with_empty_hash
+ # A hash with no key-value pairs is empty.
+ # {}.empty? => true
+ @context['empty_hash'] = {}
+ empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
+
+ assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal)
+ end
+
+ def test_nil_is_not_empty
+ # nil is NOT empty - empty? checks if a collection has zero elements.
+ # nil is not a collection, so it cannot be empty.
+ # This differs from blank: nil IS blank, but nil is NOT empty.
+ @context['nil_value'] = nil
+ empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
+
+ assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal)
+ end
+
private
def assert_evaluates_true(left, op, right)
- assert(Condition.new(left, op, right).evaluate(@context),
- "Evaluated false: #{left} #{op} #{right}")
+ assert(
+ Condition.new(left, op, right).evaluate(@context),
+ "Evaluated false: #{left.inspect} #{op} #{right.inspect}",
+ )
end
def assert_evaluates_false(left, op, right)
- assert(!Condition.new(left, op, right).evaluate(@context),
- "Evaluated true: #{left} #{op} #{right}")
+ assert(
+ !Condition.new(left, op, right).evaluate(@context),
+ "Evaluated true: #{left.inspect} #{op} #{right.inspect}",
+ )
end
def assert_evaluates_argument_error(left, op, right)
diff --git a/test/unit/strainer_factory_unit_test.rb b/test/unit/environment_filter_test.rb
similarity index 66%
rename from test/unit/strainer_factory_unit_test.rb
rename to test/unit/environment_filter_test.rb
index 43eafc1d4..e710573b1 100644
--- a/test/unit/strainer_factory_unit_test.rb
+++ b/test/unit/environment_filter_test.rb
@@ -2,7 +2,7 @@
require 'test_helper'
-class StrainerFactoryUnitTest < Minitest::Test
+class EnvironmentFilterTest < Minitest::Test
include Liquid
module AccessScopeFilters
@@ -16,8 +16,6 @@ def private_filter
private :private_filter
end
- StrainerFactory.add_global_filter(AccessScopeFilters)
-
module LateAddedFilter
def late_added_filter(_input)
"filtered"
@@ -25,24 +23,28 @@ def late_added_filter(_input)
end
def setup
- @context = Context.build
+ @environment = Liquid::Environment.build do |env|
+ env.register_filter(AccessScopeFilters)
+ end
+
+ @context = Context.build(environment: @environment)
end
def test_strainer
- strainer = StrainerFactory.create(@context)
+ strainer = @environment.create_strainer(@context)
assert_equal(5, strainer.invoke('size', 'input'))
assert_equal("public", strainer.invoke("public_filter"))
end
- def test_stainer_raises_argument_error
- strainer = StrainerFactory.create(@context)
+ def test_strainer_raises_argument_error
+ strainer = @environment.create_strainer(@context)
assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1)
end
end
- def test_stainer_argument_error_contains_backtrace
- strainer = StrainerFactory.create(@context)
+ def test_strainer_argument_error_contains_backtrace
+ strainer = @environment.create_strainer(@context)
exception = assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1)
@@ -50,14 +52,15 @@ def test_stainer_argument_error_contains_backtrace
assert_match(
/\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/,
- exception.message
+ exception.message,
)
+
source = AccessScopeFilters.instance_method(:public_filter).source_location
- assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1])
+ assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1])
end
def test_strainer_only_invokes_public_filter_methods
- strainer = StrainerFactory.create(@context)
+ strainer = @environment.create_strainer(@context)
assert_equal(false, strainer.class.invokable?('__test__'))
assert_equal(false, strainer.class.invokable?('test'))
assert_equal(false, strainer.class.invokable?('instance_eval'))
@@ -66,18 +69,18 @@ def test_strainer_only_invokes_public_filter_methods
end
def test_strainer_returns_nil_if_no_filter_method_found
- strainer = StrainerFactory.create(@context)
+ strainer = @environment.create_strainer(@context)
assert_nil(strainer.invoke("private_filter"))
assert_nil(strainer.invoke("undef_the_filter"))
end
def test_strainer_returns_first_argument_if_no_method_and_arguments_given
- strainer = StrainerFactory.create(@context)
+ strainer = @environment.create_strainer(@context)
assert_equal("password", strainer.invoke("undef_the_method", "password"))
end
def test_strainer_only_allows_methods_defined_in_filters
- strainer = StrainerFactory.create(@context)
+ strainer = @environment.create_strainer(@context)
assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1"))
assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom"))
assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke"))
@@ -86,7 +89,9 @@ def test_strainer_only_allows_methods_defined_in_filters
def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation
a = Module.new
b = Module.new
- strainer = StrainerFactory.create(@context, [a, b])
+
+ strainer = @environment.create_strainer(@context, [a, b])
+
assert_kind_of(StrainerTemplate, strainer)
assert_kind_of(a, strainer)
assert_kind_of(b, strainer)
@@ -94,8 +99,10 @@ def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation
end
def test_add_global_filter_clears_cache
- assert_equal('input', StrainerFactory.create(@context).invoke('late_added_filter', 'input'))
- StrainerFactory.add_global_filter(LateAddedFilter)
- assert_equal('filtered', StrainerFactory.create(nil).invoke('late_added_filter', 'input'))
+ assert_equal('input', @environment.create_strainer(@context).invoke('late_added_filter', 'input'))
+
+ @environment.register_filter(LateAddedFilter)
+
+ assert_equal('filtered', @environment.create_strainer(nil).invoke('late_added_filter', 'input'))
end
end
diff --git a/test/unit/environment_test.rb b/test/unit/environment_test.rb
new file mode 100644
index 000000000..e989dc9bc
--- /dev/null
+++ b/test/unit/environment_test.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class EnvironmentTest < Minitest::Test
+ include Liquid
+
+ class UnsubscribeFooter < Liquid::Tag
+ def render(_context)
+ 'Unsubscribe Footer'
+ end
+ end
+
+ def test_custom_tag
+ email_environment = Liquid::Environment.build do |environment|
+ environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
+ end
+
+ assert(email_environment.tags["unsubscribe_footer"])
+ assert(email_environment.tag_for_name("unsubscribe_footer"))
+ template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment)
+
+ assert_equal('Unsubscribe Footer', template.render)
+ end
+end
diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb
index ad4a5bd3e..73eeb7398 100644
--- a/test/unit/lexer_unit_test.rb
+++ b/test/unit/lexer_unit_test.rb
@@ -6,48 +6,144 @@ class LexerUnitTest < Minitest::Test
include Liquid
def test_strings
- tokens = Lexer.new(%( 'this is a test""' "wat 'lol'")).tokenize
- assert_equal([[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]], tokens)
+ assert_equal(
+ [[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]],
+ tokenize(%( 'this is a test""' "wat 'lol'")),
+ )
end
def test_integer
- tokens = Lexer.new('hi 50').tokenize
- assert_equal([[:id, 'hi'], [:number, '50'], [:end_of_string]], tokens)
+ assert_equal(
+ [[:id, 'hi'], [:number, '50'], [:end_of_string]],
+ tokenize('hi 50'),
+ )
end
def test_float
- tokens = Lexer.new('hi 5.0').tokenize
- assert_equal([[:id, 'hi'], [:number, '5.0'], [:end_of_string]], tokens)
+ assert_equal(
+ [[:id, 'hi'], [:number, '5.0'], [:end_of_string]],
+ tokenize('hi 5.0'),
+ )
end
def test_comparison
- tokens = Lexer.new('== <> contains ').tokenize
- assert_equal([[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokens)
+ assert_equal(
+ [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
+ tokenize('== <> contains '),
+ )
+ end
+
+ def test_comparison_without_whitespace
+ assert_equal(
+ [[:number, '1'], [:comparison, '>'], [:number, '0'], [:end_of_string]],
+ tokenize('1>0'),
+ )
+ end
+
+ def test_comparison_with_negative_number
+ assert_equal(
+ [[:number, '1'], [:comparison, '>'], [:number, '-1'], [:end_of_string]],
+ tokenize('1>-1'),
+ )
+ end
+
+ def test_raise_for_invalid_comparison
+ assert_raises(SyntaxError) do
+ tokenize('1>!1')
+ end
+
+ assert_raises(SyntaxError) do
+ tokenize('1=<1')
+ end
+
+ assert_raises(SyntaxError) do
+ tokenize('1!!1')
+ end
end
def test_specials
- tokens = Lexer.new('| .:').tokenize
- assert_equal([[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]], tokens)
- tokens = Lexer.new('[,]').tokenize
- assert_equal([[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]], tokens)
+ assert_equal(
+ [[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]],
+ tokenize('| .:'),
+ )
+
+ assert_equal(
+ [[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]],
+ tokenize('[,]'),
+ )
end
def test_fancy_identifiers
- tokens = Lexer.new('hi five?').tokenize
- assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokens)
+ assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokenize('hi five?'))
- tokens = Lexer.new('2foo').tokenize
- assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokens)
+ assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokenize('2foo'))
end
def test_whitespace
- tokens = Lexer.new("five|\n\t ==").tokenize
- assert_equal([[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]], tokens)
+ assert_equal(
+ [[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]],
+ tokenize("five|\n\t =="),
+ )
end
def test_unexpected_character
assert_raises(SyntaxError) do
- Lexer.new("%").tokenize
+ tokenize("%")
end
end
+
+ def test_negative_numbers
+ assert_equal(
+ [[:id, 'foo'], [:pipe, '|'], [:id, 'default'], [:colon, ":"], [:number, '-1'], [:end_of_string]],
+ tokenize("foo | default: -1"),
+ )
+ end
+
+ def test_greater_than_two_digits
+ assert_equal(
+ [[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]],
+ tokenize("foo > 12"),
+ )
+ end
+
+ def test_error_with_utf8_character
+ error = assert_raises(SyntaxError) do
+ tokenize("1 < 1Ø")
+ end
+
+ assert_equal(
+ 'Liquid syntax error: Unexpected character Ø',
+ error.message,
+ )
+ end
+
+ def test_contains_as_attribute_name
+ assert_equal(
+ [[:id, "a"], [:dot, "."], [:id, "contains"], [:dot, "."], [:id, "b"], [:end_of_string]],
+ tokenize("a.contains.b"),
+ )
+ end
+
+ def test_tokenize_incomplete_expression
+ assert_equal([[:id, "false"], [:dash, "-"], [:end_of_string]], tokenize("false -"))
+ assert_equal([[:id, "false"], [:comparison, "<"], [:end_of_string]], tokenize("false <"))
+ assert_equal([[:id, "false"], [:comparison, ">"], [:end_of_string]], tokenize("false >"))
+ assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1"))
+ end
+
+ def test_error_with_invalid_utf8
+ error = assert_raises(SyntaxError) do
+ tokenize("\x00\xff")
+ end
+ assert_equal(
+ 'Liquid syntax error: Invalid byte sequence in UTF-8',
+ error.message,
+ )
+ end
+
+ private
+
+ def tokenize(input)
+ Lexer.tokenize(StringScanner.new(input))
+ end
end
diff --git a/test/unit/parse_context_unit_test.rb b/test/unit/parse_context_unit_test.rb
new file mode 100644
index 000000000..f53cd1148
--- /dev/null
+++ b/test/unit/parse_context_unit_test.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class ParseContextUnitTest < Minitest::Test
+ include Liquid
+
+ def test_safe_parse_expression_with_variable_lookup
+ parser_strict = strict_parse_context.new_parser('product.title')
+ result_strict = strict_parse_context.safe_parse_expression(parser_strict)
+
+ parser_strict2 = strict2_parse_context.new_parser('product.title')
+ result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2)
+
+ assert_instance_of(VariableLookup, result_strict)
+ assert_equal('product', result_strict.name)
+ assert_equal(['title'], result_strict.lookups)
+
+ assert_instance_of(VariableLookup, result_strict2)
+ assert_equal('product', result_strict2.name)
+ assert_equal(['title'], result_strict2.lookups)
+ end
+
+ def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
+ parser_strict = strict_parse_context.new_parser('')
+ parser_strict2 = strict2_parse_context.new_parser('')
+
+ error_strict = assert_raises(Liquid::SyntaxError) do
+ strict_parse_context.safe_parse_expression(parser_strict)
+ end
+ assert_match(/is not a valid expression/, error_strict.message)
+
+ error_strict2 = assert_raises(Liquid::SyntaxError) do
+ strict2_parse_context.safe_parse_expression(parser_strict2)
+ end
+
+ assert_match(/is not a valid expression/, error_strict2.message)
+ end
+
+ def test_parse_expression_with_variable_lookup
+ result_strict = strict_parse_context.parse_expression('product.title')
+
+ assert_instance_of(VariableLookup, result_strict)
+ assert_equal('product', result_strict.name)
+ assert_equal(['title'], result_strict.lookups)
+
+ error = assert_raises(Liquid::InternalError) do
+ strict2_parse_context.parse_expression('product.title')
+ end
+
+ assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
+ end
+
+ def test_parse_expression_with_safe_true
+ result_strict = strict_parse_context.parse_expression('product.title', safe: true)
+
+ assert_instance_of(VariableLookup, result_strict)
+ assert_equal('product', result_strict.name)
+ assert_equal(['title'], result_strict.lookups)
+
+ result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
+
+ assert_instance_of(VariableLookup, result_strict2)
+ assert_equal('product', result_strict2.name)
+ assert_equal(['title'], result_strict2.lookups)
+ end
+
+ def test_parse_expression_with_empty_string
+ result_strict = strict_parse_context.parse_expression('')
+ assert_nil(result_strict)
+
+ error = assert_raises(Liquid::InternalError) do
+ strict2_parse_context.parse_expression('')
+ end
+
+ assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
+ end
+
+ def test_parse_expression_with_empty_string_and_safe_true
+ result_strict = strict_parse_context.parse_expression('', safe: true)
+ assert_nil(result_strict)
+
+ result_strict2 = strict2_parse_context.parse_expression('', safe: true)
+ assert_nil(result_strict2)
+ end
+
+ def test_safe_parse_expression_advances_parser_pointer
+ parser = strict2_parse_context.new_parser('foo, bar')
+
+ # safe_parse_expression consumes "foo"
+ first_result = strict2_parse_context.safe_parse_expression(parser)
+ assert_instance_of(VariableLookup, first_result)
+ assert_equal('foo', first_result.name)
+
+ parser.consume(:comma)
+
+ # safe_parse_expression consumes "bar"
+ second_result = strict2_parse_context.safe_parse_expression(parser)
+ assert_instance_of(VariableLookup, second_result)
+ assert_equal('bar', second_result.name)
+
+ parser.consume(:end_of_string)
+ end
+
+ def test_parse_expression_with_whitespace_in_strict2_mode
+ result = strict2_parse_context.parse_expression(' ', safe: true)
+ assert_nil(result)
+ end
+
+ private
+
+ def strict_parse_context
+ @strict_parse_context ||= ParseContext.new(
+ environment: Environment.build(error_mode: :strict),
+ )
+ end
+
+ def strict2_parse_context
+ @strict2_parse_context ||= ParseContext.new(
+ environment: Environment.build(error_mode: :strict2),
+ )
+ end
+end
diff --git a/test/unit/parse_tree_visitor_test.rb b/test/unit/parse_tree_visitor_test.rb
index 71a902e19..6d946f225 100644
--- a/test/unit/parse_tree_visitor_test.rb
+++ b/test/unit/parse_tree_visitor_test.rb
@@ -8,245 +8,245 @@ class ParseTreeVisitorTest < Minitest::Test
def test_variable
assert_equal(
["test"],
- visit(%({{ test }}))
+ visit(%({{ test }})),
)
end
def test_varible_with_filter
assert_equal(
["test", "infilter"],
- visit(%({{ test | split: infilter }}))
+ visit(%({{ test | split: infilter }})),
)
end
def test_dynamic_variable
assert_equal(
["test", "inlookup"],
- visit(%({{ test[inlookup] }}))
+ visit(%({{ test[inlookup] }})),
)
end
def test_echo
assert_equal(
["test"],
- visit(%({% echo test %}))
+ visit(%({% echo test %})),
)
end
def test_if_condition
assert_equal(
["test"],
- visit(%({% if test %}{% endif %}))
+ visit(%({% if test %}{% endif %})),
)
end
def test_complex_if_condition
assert_equal(
["test"],
- visit(%({% if 1 == 1 and 2 == test %}{% endif %}))
+ visit(%({% if 1 == 1 and 2 == test %}{% endif %})),
)
end
def test_if_body
assert_equal(
["test"],
- visit(%({% if 1 == 1 %}{{ test }}{% endif %}))
+ visit(%({% if 1 == 1 %}{{ test }}{% endif %})),
)
end
def test_unless_condition
assert_equal(
["test"],
- visit(%({% unless test %}{% endunless %}))
+ visit(%({% unless test %}{% endunless %})),
)
end
def test_complex_unless_condition
assert_equal(
["test"],
- visit(%({% unless 1 == 1 and 2 == test %}{% endunless %}))
+ visit(%({% unless 1 == 1 and 2 == test %}{% endunless %})),
)
end
def test_unless_body
assert_equal(
["test"],
- visit(%({% unless 1 == 1 %}{{ test }}{% endunless %}))
+ visit(%({% unless 1 == 1 %}{{ test }}{% endunless %})),
)
end
def test_elsif_condition
assert_equal(
["test"],
- visit(%({% if 1 == 1 %}{% elsif test %}{% endif %}))
+ visit(%({% if 1 == 1 %}{% elsif test %}{% endif %})),
)
end
def test_complex_elsif_condition
assert_equal(
["test"],
- visit(%({% if 1 == 1 %}{% elsif 1 == 1 and 2 == test %}{% endif %}))
+ visit(%({% if 1 == 1 %}{% elsif 1 == 1 and 2 == test %}{% endif %})),
)
end
def test_elsif_body
assert_equal(
["test"],
- visit(%({% if 1 == 1 %}{% elsif 2 == 2 %}{{ test }}{% endif %}))
+ visit(%({% if 1 == 1 %}{% elsif 2 == 2 %}{{ test }}{% endif %})),
)
end
def test_else_body
assert_equal(
["test"],
- visit(%({% if 1 == 1 %}{% else %}{{ test }}{% endif %}))
+ visit(%({% if 1 == 1 %}{% else %}{{ test }}{% endif %})),
)
end
def test_case_left
assert_equal(
["test"],
- visit(%({% case test %}{% endcase %}))
+ visit(%({% case test %}{% endcase %})),
)
end
def test_case_condition
assert_equal(
["test"],
- visit(%({% case 1 %}{% when test %}{% endcase %}))
+ visit(%({% case 1 %}{% when test %}{% endcase %})),
)
end
def test_case_when_body
assert_equal(
["test"],
- visit(%({% case 1 %}{% when 2 %}{{ test }}{% endcase %}))
+ visit(%({% case 1 %}{% when 2 %}{{ test }}{% endcase %})),
)
end
def test_case_else_body
assert_equal(
["test"],
- visit(%({% case 1 %}{% else %}{{ test }}{% endcase %}))
+ visit(%({% case 1 %}{% else %}{{ test }}{% endcase %})),
)
end
def test_for_in
assert_equal(
["test"],
- visit(%({% for x in test %}{% endfor %}))
+ visit(%({% for x in test %}{% endfor %})),
)
end
def test_for_limit
assert_equal(
["test"],
- visit(%({% for x in (1..5) limit: test %}{% endfor %}))
+ visit(%({% for x in (1..5) limit: test %}{% endfor %})),
)
end
def test_for_offset
assert_equal(
["test"],
- visit(%({% for x in (1..5) offset: test %}{% endfor %}))
+ visit(%({% for x in (1..5) offset: test %}{% endfor %})),
)
end
def test_for_body
assert_equal(
["test"],
- visit(%({% for x in (1..5) %}{{ test }}{% endfor %}))
+ visit(%({% for x in (1..5) %}{{ test }}{% endfor %})),
)
end
def test_for_range
assert_equal(
["test"],
- visit(%({% for x in (1..test) %}{% endfor %}))
+ visit(%({% for x in (1..test) %}{% endfor %})),
)
end
def test_tablerow_in
assert_equal(
["test"],
- visit(%({% tablerow x in test %}{% endtablerow %}))
+ visit(%({% tablerow x in test %}{% endtablerow %})),
)
end
def test_tablerow_limit
assert_equal(
["test"],
- visit(%({% tablerow x in (1..5) limit: test %}{% endtablerow %}))
+ visit(%({% tablerow x in (1..5) limit: test %}{% endtablerow %})),
)
end
def test_tablerow_offset
assert_equal(
["test"],
- visit(%({% tablerow x in (1..5) offset: test %}{% endtablerow %}))
+ visit(%({% tablerow x in (1..5) offset: test %}{% endtablerow %})),
)
end
def test_tablerow_body
assert_equal(
["test"],
- visit(%({% tablerow x in (1..5) %}{{ test }}{% endtablerow %}))
+ visit(%({% tablerow x in (1..5) %}{{ test }}{% endtablerow %})),
)
end
def test_cycle
assert_equal(
["test"],
- visit(%({% cycle test %}))
+ visit(%({% cycle test %})),
)
end
def test_assign
assert_equal(
["test"],
- visit(%({% assign x = test %}))
+ visit(%({% assign x = test %})),
)
end
def test_capture
assert_equal(
["test"],
- visit(%({% capture x %}{{ test }}{% endcapture %}))
+ visit(%({% capture x %}{{ test }}{% endcapture %})),
)
end
def test_include
assert_equal(
["test"],
- visit(%({% include test %}))
+ visit(%({% include test %})),
)
end
def test_include_with
assert_equal(
["test"],
- visit(%({% include "hai" with test %}))
+ visit(%({% include "hai" with test %})),
)
end
def test_include_for
assert_equal(
["test"],
- visit(%({% include "hai" for test %}))
+ visit(%({% include "hai" for test %})),
)
end
def test_render_with
assert_equal(
["test"],
- visit(%({% render "hai" with test %}))
+ visit(%({% render "hai" with test %})),
)
end
def test_render_for
assert_equal(
["test"],
- visit(%({% render "hai" for test %}))
+ visit(%({% render "hai" for test %})),
)
end
@@ -257,7 +257,7 @@ def test_preserve_tree_structure
["test", []],
["xs", []],
]]],
- traversal(%({% for x in xs offset: test %}{{ other }}{% endfor %})).visit
+ traversal(%({% for x in xs offset: test %}{{ other }}{% endfor %})).visit,
)
end
diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb
index 130b8b793..2c6a3594e 100644
--- a/test/unit/parser_unit_test.rb
+++ b/test/unit/parser_unit_test.rb
@@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test
include Liquid
def test_consume
- p = Parser.new("wat: 7")
+ p = new_parser("wat: 7")
assert_equal('wat', p.consume(:id))
assert_equal(':', p.consume(:colon))
assert_equal('7', p.consume(:number))
end
def test_jump
- p = Parser.new("wat: 7")
+ p = new_parser("wat: 7")
p.jump(2)
assert_equal('7', p.consume(:number))
end
def test_consume?
- p = Parser.new("wat: 7")
+ p = new_parser("wat: 7")
assert_equal('wat', p.consume?(:id))
assert_equal(false, p.consume?(:dot))
assert_equal(':', p.consume(:colon))
@@ -27,7 +27,7 @@ def test_consume?
end
def test_id?
- p = Parser.new("wat 6 Peter Hegemon")
+ p = new_parser("wat 6 Peter Hegemon")
assert_equal('wat', p.id?('wat'))
assert_equal(false, p.id?('endgame'))
assert_equal('6', p.consume(:number))
@@ -36,7 +36,7 @@ def test_id?
end
def test_look
- p = Parser.new("wat 6 Peter Hegemon")
+ p = new_parser("wat 6 Peter Hegemon")
assert_equal(true, p.look(:id))
assert_equal('wat', p.consume(:id))
assert_equal(false, p.look(:comparison))
@@ -46,12 +46,12 @@ def test_look
end
def test_expressions
- p = Parser.new("hi.there hi?[5].there? hi.there.bob")
+ p = new_parser("hi.there hi?[5].there? hi.there.bob")
assert_equal('hi.there', p.expression)
assert_equal('hi?[5].there?', p.expression)
assert_equal('hi.there.bob', p.expression)
- p = Parser.new("567 6.0 'lol' \"wut\"")
+ p = new_parser("567 6.0 'lol' \"wut\"")
assert_equal('567', p.expression)
assert_equal('6.0', p.expression)
assert_equal("'lol'", p.expression)
@@ -59,7 +59,7 @@ def test_expressions
end
def test_ranges
- p = Parser.new("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
+ p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
assert_equal('(5..7)', p.expression)
assert_equal('(1.5..9.6)', p.expression)
assert_equal('(young..old)', p.expression)
@@ -67,7 +67,7 @@ def test_ranges
end
def test_arguments
- p = Parser.new("filter: hi.there[5], keyarg: 7")
+ p = new_parser("filter: hi.there[5], keyarg: 7")
assert_equal('filter', p.consume(:id))
assert_equal(':', p.consume(:colon))
assert_equal('hi.there[5]', p.argument)
@@ -77,8 +77,14 @@ def test_arguments
def test_invalid_expression
assert_raises(SyntaxError) do
- p = Parser.new("==")
+ p = new_parser("==")
p.expression
end
end
+
+ private
+
+ def new_parser(str)
+ Parser.new(StringScanner.new(str))
+ end
end
diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb
index b0a2483bb..623241b40 100644
--- a/test/unit/partial_cache_unit_test.rb
+++ b/test/unit/partial_cache_unit_test.rb
@@ -7,13 +7,13 @@ def test_uses_the_file_system_register_if_present
context = Liquid::Context.build(
registers: {
file_system: StubFileSystem.new('my_partial' => 'my partial body'),
- }
+ },
)
partial = Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: Liquid::ParseContext.new
+ parse_context: Liquid::ParseContext.new,
)
assert_equal('my partial body', partial.render)
@@ -22,14 +22,14 @@ def test_uses_the_file_system_register_if_present
def test_reads_from_the_file_system_only_once_per_file
file_system = StubFileSystem.new('my_partial' => 'some partial body')
context = Liquid::Context.build(
- registers: { file_system: file_system }
+ registers: { file_system: file_system },
)
2.times do
Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: Liquid::ParseContext.new
+ parse_context: Liquid::ParseContext.new,
)
end
@@ -39,31 +39,31 @@ def test_reads_from_the_file_system_only_once_per_file
def test_cache_state_is_stored_per_context
parse_context = Liquid::ParseContext.new
shared_file_system = StubFileSystem.new(
- 'my_partial' => 'my shared value'
+ 'my_partial' => 'my shared value',
)
context_one = Liquid::Context.build(
registers: {
file_system: shared_file_system,
- }
+ },
)
context_two = Liquid::Context.build(
registers: {
file_system: shared_file_system,
- }
+ },
)
2.times do
Liquid::PartialCache.load(
'my_partial',
context: context_one,
- parse_context: parse_context
+ parse_context: parse_context,
)
end
Liquid::PartialCache.load(
'my_partial',
context: context_two,
- parse_context: parse_context
+ parse_context: parse_context,
)
assert_equal(2, shared_file_system.file_read_count)
@@ -72,18 +72,18 @@ def test_cache_state_is_stored_per_context
def test_cache_is_not_broken_when_a_different_parse_context_is_used
file_system = StubFileSystem.new('my_partial' => 'some partial body')
context = Liquid::Context.build(
- registers: { file_system: file_system }
+ registers: { file_system: file_system },
)
Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: Liquid::ParseContext.new(my_key: 'value one')
+ parse_context: Liquid::ParseContext.new(my_key: 'value one'),
)
Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: Liquid::ParseContext.new(my_key: 'value two')
+ parse_context: Liquid::ParseContext.new(my_key: 'value two'),
)
# Technically what we care about is that the file was parsed twice,
@@ -95,13 +95,13 @@ def test_uses_default_template_factory_when_no_template_factory_found_in_registe
context = Liquid::Context.build(
registers: {
file_system: StubFileSystem.new('my_partial' => 'my partial body'),
- }
+ },
)
partial = Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: Liquid::ParseContext.new
+ parse_context: Liquid::ParseContext.new,
)
assert_equal('my partial body', partial.render)
@@ -113,13 +113,13 @@ def test_uses_template_factory_register_if_present
registers: {
file_system: StubFileSystem.new('my_partial' => 'my partial body'),
template_factory: template_factory,
- }
+ },
)
partial = Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: Liquid::ParseContext.new
+ parse_context: Liquid::ParseContext.new,
)
assert_equal('my partial body', partial.render)
@@ -129,12 +129,12 @@ def test_uses_template_factory_register_if_present
def test_cache_state_is_shared_for_subcontexts
parse_context = Liquid::ParseContext.new
shared_file_system = StubFileSystem.new(
- 'my_partial' => 'my shared value'
+ 'my_partial' => 'my shared value',
)
context = Liquid::Context.build(
registers: Liquid::Registers.new(
file_system: shared_file_system,
- )
+ ),
)
subcontext = context.new_isolated_subcontext
@@ -144,16 +144,57 @@ def test_cache_state_is_shared_for_subcontexts
Liquid::PartialCache.load(
'my_partial',
context: context,
- parse_context: parse_context
+ parse_context: parse_context,
)
Liquid::PartialCache.load(
'my_partial',
context: subcontext,
- parse_context: parse_context
+ parse_context: parse_context,
)
end
assert_equal(1, shared_file_system.file_read_count)
end
+
+ def test_uses_template_name_from_template_factory
+ template_factory = StubTemplateFactory.new
+ context = Liquid::Context.build(
+ registers: {
+ file_system: StubFileSystem.new('my_partial' => 'my partial body'),
+ template_factory: template_factory,
+ },
+ )
+
+ partial = Liquid::PartialCache.load(
+ 'my_partial',
+ context: context,
+ parse_context: Liquid::ParseContext.new,
+ )
+
+ assert_equal('some/path/my_partial', partial.name)
+ end
+
+ def test_includes_error_mode_into_template_cache
+ template_factory = StubTemplateFactory.new
+ context = Liquid::Context.build(
+ registers: {
+ file_system: StubFileSystem.new('my_partial' => 'my partial body'),
+ template_factory: template_factory,
+ },
+ )
+
+ [:lax, :warn, :strict, :strict2].each do |error_mode|
+ Liquid::PartialCache.load(
+ 'my_partial',
+ context: context,
+ parse_context: Liquid::ParseContext.new(error_mode: error_mode),
+ )
+ end
+
+ assert_equal(
+ ["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"],
+ context.registers[:cached_partials].keys,
+ )
+ end
end
diff --git a/test/unit/range_slice_unit_test.rb b/test/unit/range_slice_unit_test.rb
new file mode 100644
index 000000000..876c83e90
--- /dev/null
+++ b/test/unit/range_slice_unit_test.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class RangeSliceUnitTest < Minitest::Test
+ def test_selects_and_reverses_an_integer_range_without_enumerating_it
+ limits = Liquid::ResourceLimits.new({})
+ slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 997, 999, limits)
+
+ assert_equal(2, slice.length)
+ refute(slice.empty?)
+ slice.reverse!
+ assert_equal([999, 998], slice.each.to_a)
+ assert_equal(2, limits.render_score)
+ end
+
+ def test_charges_only_values_yielded_before_a_break
+ limits = Liquid::ResourceLimits.new({})
+ slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 0, nil, limits)
+
+ slice.each { break }
+
+ assert_equal(1, limits.render_score)
+ end
+
+ def test_non_integer_ranges_are_sliced_without_to_a_and_charge_visited_values
+ limits = Liquid::ResourceLimits.new({})
+ range = Class.new(Range) do
+ def to_a
+ raise 'range was materialized'
+ end
+ end.new('a', 'c')
+
+ assert_equal(['b', 'c'], Liquid::Utils.slice_collection_for_iteration(range, 1, nil, limits))
+ assert_equal(3, limits.render_score)
+
+ limited = Liquid::ResourceLimits.new(render_score_limit: 2)
+ assert_raises(Liquid::MemoryError) do
+ Liquid::Utils.slice_collection_for_iteration('a'..'z', 10, 11, limited)
+ end
+ end
+
+ def test_non_integer_range_empty_windows_do_not_visit_a_sentinel_value
+ limits = Liquid::ResourceLimits.new({})
+
+ assert_equal([], Liquid::Utils.slice_collection_for_iteration('a'..'z', 2, 2, limits))
+ assert_equal(0, limits.render_score)
+ assert_equal(['a', 'b'], Liquid::Utils.slice_collection_for_iteration('a'..'z', 0, 2, limits))
+ assert_equal(2, limits.render_score)
+ end
+
+ def test_standard_beginless_range_raises_for_empty_windows
+ [[0, 0], [1, 0]].each do |from, to|
+ assert_raises(TypeError) do
+ Liquid::Utils.slice_collection_for_iteration(Range.new(nil, 3), from, to, Liquid::ResourceLimits.new({}))
+ end
+ end
+ end
+
+ def test_custom_range_to_a_is_sliced_with_a_budget
+ range = Class.new(Range) do
+ def to_a
+ [1, 2, 3]
+ end
+ end.new(nil, 3)
+ limits = Liquid::ResourceLimits.new(render_score_limit: 2)
+
+ assert_raises(Liquid::MemoryError) do
+ Liquid::Utils.slice_collection_for_iteration(range, 1, 3, limits, use_range_to_a: true)
+ end
+ assert_equal(3, limits.render_score)
+ end
+
+ def test_preserves_slice_bounds_for_negative_offsets_and_limits
+ limits = Liquid::ResourceLimits.new({})
+
+ assert_equal([1, 2], Liquid::RangeSlice.new(1..5, -2, 2, limits).each.to_a)
+ empty = Liquid::RangeSlice.new(1..5, 2, 1, limits)
+ assert(empty.empty?)
+ assert_equal([], empty.each.to_a)
+ assert_equal(2, limits.render_score) # the empty window performs no work
+ assert(Liquid::RangeSlice.new(5..1, 0, nil, limits).empty?)
+ assert_equal([1, 2, 3, 4], Liquid::RangeSlice.new(1...5, 0, nil, limits).each.to_a)
+ end
+end
diff --git a/test/unit/regexp_unit_test.rb b/test/unit/regexp_unit_test.rb
index aad663c1b..877c95e45 100644
--- a/test/unit/regexp_unit_test.rb
+++ b/test/unit/regexp_unit_test.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'test_helper'
+require 'timeout'
class RegexpUnitTest < Minitest::Test
include Liquid
@@ -37,10 +38,22 @@ def test_quoted_words_in_the_middle
def test_variable_parser
assert_equal(['var'], 'var'.scan(VariableParser))
+ assert_equal(['[var]'], '[var]'.scan(VariableParser))
assert_equal(['var', 'method'], 'var.method'.scan(VariableParser))
assert_equal(['var', '[method]'], 'var[method]'.scan(VariableParser))
assert_equal(['var', '[method]', '[0]'], 'var[method][0]'.scan(VariableParser))
assert_equal(['var', '["method"]', '[0]'], 'var["method"][0]'.scan(VariableParser))
assert_equal(['var', '[method]', '[0]', 'method'], 'var[method][0].method'.scan(VariableParser))
end
+
+ def test_variable_parser_with_large_input
+ Timeout.timeout(1) { assert_equal(['[var]'], '[var]'.scan(VariableParser)) }
+
+ very_long_string = "foo" * 1000
+
+ # valid dynamic lookup
+ Timeout.timeout(1) { assert_equal(["[#{very_long_string}]"], "[#{very_long_string}]".scan(VariableParser)) }
+ # invalid dynamic lookup with missing closing bracket
+ Timeout.timeout(1) { assert_equal([very_long_string], "[#{very_long_string}".scan(VariableParser)) }
+ end
end # RegexpTest
diff --git a/test/unit/resource_limits_unit_test.rb b/test/unit/resource_limits_unit_test.rb
new file mode 100644
index 000000000..b5498e367
--- /dev/null
+++ b/test/unit/resource_limits_unit_test.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class ResourceLimitsUnitTest < Minitest::Test
+ def test_cumulative_scores_initialize_to_zero
+ limits = Liquid::ResourceLimits.new({})
+ assert_equal(0, limits.cumulative_render_score)
+ assert_equal(0, limits.cumulative_assign_score)
+ end
+
+ def test_cumulative_limits_default_to_nil
+ limits = Liquid::ResourceLimits.new({})
+ assert_nil(limits.cumulative_render_score_limit)
+ assert_nil(limits.cumulative_assign_score_limit)
+ end
+
+ def test_cumulative_limits_configurable_via_hash
+ limits = Liquid::ResourceLimits.new(
+ cumulative_render_score_limit: 500,
+ cumulative_assign_score_limit: 300,
+ )
+ assert_equal(500, limits.cumulative_render_score_limit)
+ assert_equal(300, limits.cumulative_assign_score_limit)
+ end
+
+ def test_cumulative_limits_configurable_via_accessor
+ limits = Liquid::ResourceLimits.new({})
+ limits.cumulative_render_score_limit = 500
+ assert_equal(500, limits.cumulative_render_score_limit)
+ end
+
+ def test_cumulative_scores_survive_reset
+ limits = Liquid::ResourceLimits.new({})
+ limits.increment_render_score(10)
+ limits.increment_assign_score(5)
+
+ limits.reset
+
+ assert_equal(0, limits.render_score)
+ assert_equal(0, limits.assign_score)
+ assert_equal(10, limits.cumulative_render_score)
+ assert_equal(5, limits.cumulative_assign_score)
+ end
+
+ def test_cumulative_scores_accumulate_across_resets
+ limits = Liquid::ResourceLimits.new({})
+ limits.increment_render_score(10)
+ limits.reset
+ limits.increment_render_score(20)
+ limits.reset
+ limits.increment_render_score(30)
+
+ assert_equal(30, limits.render_score)
+ assert_equal(60, limits.cumulative_render_score)
+ end
+
+ def test_cumulative_render_score_limit_raises
+ limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
+ limits.increment_render_score(10)
+ limits.reset
+ limits.increment_render_score(10)
+ limits.reset
+
+ assert_raises(Liquid::MemoryError) do
+ limits.increment_render_score(10)
+ end
+ assert(limits.reached?)
+ end
+
+ def test_cumulative_assign_score_limit_raises
+ limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
+ limits.increment_assign_score(8)
+ limits.reset
+
+ assert_raises(Liquid::MemoryError) do
+ limits.increment_assign_score(8)
+ end
+ assert(limits.reached?)
+ end
+
+ def test_per_template_limits_still_work_with_cumulative
+ limits = Liquid::ResourceLimits.new(
+ render_score_limit: 50,
+ cumulative_render_score_limit: 1000,
+ )
+ assert_raises(Liquid::MemoryError) do
+ limits.increment_render_score(51)
+ end
+ end
+end
diff --git a/test/unit/strainer_template_unit_test.rb b/test/unit/strainer_template_unit_test.rb
index fd6a5a3ca..aa3f153a3 100644
--- a/test/unit/strainer_template_unit_test.rb
+++ b/test/unit/strainer_template_unit_test.rb
@@ -8,7 +8,7 @@ class StrainerTemplateUnitTest < Minitest::Test
def test_add_filter_when_wrong_filter_class
c = Context.new
s = c.strainer
- wrong_filter = ->(v) { v.reverse }
+ wrong_filter = lambda(&:reverse)
exception = assert_raises(TypeError) do
s.class.add_filter(wrong_filter)
@@ -25,11 +25,13 @@ def public_filter
end
def test_add_filter_raises_when_module_privately_overrides_registered_public_methods
- strainer = Context.new.strainer
-
error = assert_raises(Liquid::MethodOverrideError) do
- strainer.class.add_filter(PrivateMethodOverrideFilter)
+ Liquid::Environment.build do |env|
+ env.register_filter(PublicMethodOverrideFilter)
+ env.register_filter(PrivateMethodOverrideFilter)
+ end
end
+
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end
@@ -42,11 +44,13 @@ def public_filter
end
def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected
- strainer = Context.new.strainer
-
error = assert_raises(Liquid::MethodOverrideError) do
- strainer.class.add_filter(ProtectedMethodOverrideFilter)
+ Liquid::Environment.build do |env|
+ env.register_filter(PublicMethodOverrideFilter)
+ env.register_filter(ProtectedMethodOverrideFilter)
+ end
end
+
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end
@@ -58,8 +62,9 @@ def public_filter
def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method
with_global_filter do
- strainer = Context.new.strainer
- strainer.class.add_filter(PublicMethodOverrideFilter)
+ context = Context.new
+ context.add_filters([PublicMethodOverrideFilter])
+ strainer = context.strainer
assert(strainer.class.send(:filter_methods).include?('public_filter'))
end
end
diff --git a/test/unit/tag_unit_test.rb b/test/unit/tag_unit_test.rb
index b814b1b72..9b4bf88b6 100644
--- a/test/unit/tag_unit_test.rb
+++ b/test/unit/tag_unit_test.rb
@@ -6,18 +6,36 @@ class TagUnitTest < Minitest::Test
include Liquid
def test_tag
- tag = Tag.parse('tag', "", Tokenizer.new(""), ParseContext.new)
+ tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new)
assert_equal('liquid::tag', tag.name)
assert_equal('', tag.render(Context.new))
end
def test_return_raw_text_of_tag
- tag = Tag.parse("long_tag", "param1, param2, param3", Tokenizer.new(""), ParseContext.new)
+ tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new)
assert_equal("long_tag param1, param2, param3", tag.raw)
end
def test_tag_name_should_return_name_of_the_tag
- tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
+ tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new)
assert_equal('some_tag', tag.tag_name)
end
+
+ class CustomTag < Liquid::Tag
+ def render(_context); end
+ end
+
+ def test_tag_render_to_output_buffer_nil_value
+ custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new)
+ assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string"))
+ end
+
+ private
+
+ def new_tokenizer
+ Tokenizer.new(
+ source: "",
+ string_scanner: StringScanner.new(""),
+ )
+ end
end
diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb
index a94d167f5..11458db80 100644
--- a/test/unit/tags/case_tag_unit_test.rb
+++ b/test/unit/tags/case_tag_unit_test.rb
@@ -9,4 +9,140 @@ def test_case_nodelist
template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}')
assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten)
end
+
+ def test_case_with_trailing_element
+ template = <<~LIQUID
+ {%- case 1 bar -%}
+ {%- when 1 -%}
+ one
+ {%- else -%}
+ two
+ {%- endcase -%}
+ LIQUID
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result("one", template)
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+
+ assert_match(/Expected end_of_string but found/, error.message)
+ end
+ end
+
+ def test_case_when_with_trailing_element
+ template = <<~LIQUID
+ {%- case 1 -%}
+ {%- when 1 bar -%}
+ one
+ {%- else -%}
+ two
+ {%- endcase -%}
+ LIQUID
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result("one", template)
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+
+ assert_match(/Expected end_of_string but found/, error.message)
+ end
+ end
+
+ def test_case_when_with_comma
+ template = <<~LIQUID
+ {%- case 1 -%}
+ {%- when 2, 1 -%}
+ one
+ {%- else -%}
+ two
+ {%- endcase -%}
+ LIQUID
+
+ with_error_modes(:lax, :strict, :strict2) do
+ assert_template_result("one", template)
+ end
+ end
+
+ def test_case_when_with_or
+ template = <<~LIQUID
+ {%- case 1 -%}
+ {%- when 2 or 1 -%}
+ one
+ {%- else -%}
+ two
+ {%- endcase -%}
+ LIQUID
+
+ with_error_modes(:lax, :strict, :strict2) do
+ assert_template_result("one", template)
+ end
+ end
+
+ def test_case_when_empty
+ template = <<~LIQUID
+ {%- case x -%}
+ {%- when 2 or empty -%}
+ 2 or empty
+ {%- else -%}
+ not 2 or empty
+ {%- endcase -%}
+ LIQUID
+
+ with_error_modes(:lax, :strict, :strict2) do
+ assert_template_result("2 or empty", template, { 'x' => 2 })
+ assert_template_result("2 or empty", template, { 'x' => {} })
+ assert_template_result("2 or empty", template, { 'x' => [] })
+ assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
+ assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
+ assert_template_result("not 2 or empty", template, { 'x' => 4 })
+ end
+ end
+
+ def test_case_with_invalid_expression
+ template = <<~LIQUID
+ {%- case foo=>bar -%}
+ {%- when 'baz' -%}
+ one
+ {%- else -%}
+ two
+ {%- endcase -%}
+ LIQUID
+ assigns = { 'foo' => { 'bar' => 'baz' } }
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result("one", template, assigns)
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
+
+ def test_case_when_with_invalid_expression
+ template = <<~LIQUID
+ {%- case 'baz' -%}
+ {%- when foo=>bar -%}
+ one
+ {%- else -%}
+ two
+ {%- endcase -%}
+ LIQUID
+ assigns = { 'foo' => { 'bar' => 'baz' } }
+
+ with_error_modes(:lax, :strict) do
+ assert_template_result("one", template, assigns)
+ end
+
+ with_error_modes(:strict2) do
+ error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
+
+ assert_match(/Unexpected character =/, error.message)
+ end
+ end
end
diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb
new file mode 100644
index 000000000..56618b720
--- /dev/null
+++ b/test/unit/tags/comment_tag_unit_test.rb
@@ -0,0 +1,202 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class CommentTagUnitTest < Minitest::Test
+ def test_comment_inside_liquid_tag
+ assert_template_result("", <<~LIQUID.chomp)
+ {% liquid
+ if 1 != 1
+ comment
+ else
+ echo 123
+ endcomment
+ endif
+ %}
+ LIQUID
+ end
+
+ def test_does_not_parse_nodes_inside_a_comment
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% if true %}
+ {% if ... %}
+ {%- for ? -%}
+ {% while true %}
+ {%
+ unless if
+ %}
+ {% endcase %}
+ {% endcomment %}
+ LIQUID
+ end
+
+ def test_allows_unclosed_tags
+ assert_template_result('', <<~LIQUID.chomp)
+ {% comment %}
+ {% if true %}
+ {% endcomment %}
+ LIQUID
+ end
+
+ def test_open_tags_in_comment
+ assert_template_result('', <<~LIQUID.chomp)
+ {% comment %}
+ {% assign a = 123 {% comment %}
+ {% endcomment %}
+ LIQUID
+
+ assert_raises(Liquid::SyntaxError) do
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% assign foo = "1"
+ {% endcomment %}
+ LIQUID
+ end
+
+ assert_raises(Liquid::SyntaxError) do
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% comment %}
+ {% invalid
+ {% endcomment %}
+ {% endcomment %}
+ LIQUID
+ end
+
+ assert_raises(Liquid::SyntaxError) do
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% {{ {%- endcomment %}
+ LIQUID
+ end
+ end
+
+ def test_child_comment_tags_need_to_be_closed
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% comment %}
+ {% comment %}{% endcomment %}
+ {% endcomment %}
+ {% endcomment %}
+ LIQUID
+
+ assert_raises(Liquid::SyntaxError) do
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% comment %}
+ {% comment %}
+ {% endcomment %}
+ {% endcomment %}
+ LIQUID
+ end
+ end
+
+ def test_child_raw_tags_need_to_be_closed
+ assert_template_result("", <<~LIQUID.chomp)
+ {% comment %}
+ {% raw %}
+ {% endcomment %}
+ {% endraw %}
+ {% endcomment %}
+ LIQUID
+
+ assert_raises(Liquid::SyntaxError) do
+ Liquid::Template.parse(<<~LIQUID.chomp)
+ {% comment %}
+ {% raw %}
+ {% endcomment %}
+ {% endcomment %}
+ LIQUID
+ end
+ end
+
+ def test_error_line_number_is_correct
+ template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
+ {% comment %}
+ {% if true %}
+ {% endcomment %}
+ {{ errors.standard_error }}
+ LIQUID
+
+ output = template.render('errors' => ErrorDrop.new)
+ expected = <<~TEXT.chomp
+
+ Liquid error (line 4): standard error
+ TEXT
+
+ assert_equal(expected, output)
+ end
+
+ def test_comment_tag_delimiter_with_extra_strings
+ assert_template_result(
+ '',
+ <<~LIQUID.chomp,
+ {% comment %}
+ {% comment %}
+ {% endcomment
+ {% if true %}
+ {% endif %}
+ {% endcomment %}
+ LIQUID
+ )
+ end
+
+ def test_nested_comment_tag_with_extra_strings
+ assert_template_result(
+ '',
+ <<~LIQUID.chomp,
+ {% comment %}
+ {% comment
+ {% assign foo = 1 %}
+ {% endcomment
+ {% assign foo = 1 %}
+ {% endcomment %}
+ LIQUID
+ )
+ end
+
+ def test_ignores_delimiter_with_extra_strings
+ assert_template_result(
+ '',
+ <<~LIQUID.chomp,
+ {% if true %}
+ {% comment %}
+ {% commentXXXXX %}wut{% endcommentXXXXX %}
+ {% endcomment %}
+ {% endif %}
+ LIQUID
+ )
+ end
+
+ def test_delimiter_can_have_extra_strings
+ assert_template_result('', "{% comment %}123{% endcomment xyz %}")
+ assert_template_result('', "{% comment %}123{% endcomment\txyz %}")
+ assert_template_result('', "{% comment %}123{% endcomment\nxyz %}")
+ assert_template_result('', "{% comment %}123{% endcomment\n xyz endcomment %}")
+ assert_template_result('', "{%comment}{% assign a = 1 %}{%endcomment}{% endif %}")
+ end
+
+ def test_with_whitespace_control
+ assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%}Hello!")
+ assert_template_result("Hello!", "{%- comment -%}123{%- endcomment -%} Hello!")
+ assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%} Hello!")
+
+ assert_template_result("Hello!", <<~LIQUID.chomp)
+ {%- comment %}Whitespace control!{% endcomment -%}
+ Hello!
+ LIQUID
+ end
+
+ def test_dont_override_liquid_tag_whitespace_control
+ assert_template_result("Hello!World!", <<~LIQUID.chomp)
+ Hello!
+ {%- liquid
+ comment
+ this is inside a liquid tag
+ endcomment
+ -%}
+ World!
+ LIQUID
+ end
+end
diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb
new file mode 100644
index 000000000..223f3ca0d
--- /dev/null
+++ b/test/unit/tags/doc_tag_unit_test.rb
@@ -0,0 +1,287 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class DocTagUnitTest < Minitest::Test
+ def test_doc_tag
+ template = <<~LIQUID.chomp
+ {% doc %}
+ Renders loading-spinner.
+
+ @param {string} foo - some foo
+ @param {string} [bar] - optional bar
+
+ @example
+ {% render 'loading-spinner', foo: 'foo' %}
+ {% render 'loading-spinner', foo: 'foo', bar: 'bar' %}
+ {% enddoc %}
+ LIQUID
+
+ assert_template_result('', template)
+ end
+
+ def test_doc_tag_body_content
+ doc_content = " Documentation content\n @param {string} foo - test\n"
+ template_source = "{% doc %}#{doc_content}{% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal(doc_content, doc_tag.nodelist.first.to_s)
+ end
+
+ def test_doc_tag_does_not_support_extra_arguments
+ error = assert_raises(Liquid::SyntaxError) do
+ template = <<~LIQUID.chomp
+ {% doc extra %}
+ {% enddoc %}
+ LIQUID
+
+ Liquid::Template.parse(template)
+ end
+
+ exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}"
+ act_error = error.message
+
+ assert_equal(exp_error, act_error)
+ end
+
+ def test_doc_tag_must_support_valid_tags
+ assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo')
+ assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}')
+ assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}')
+ end
+
+ def test_doc_tag_ignores_liquid_nodes
+ template = <<~LIQUID.chomp
+ {% doc %}
+ {% if true %}
+ {% if ... %}
+ {%- for ? -%}
+ {% while true %}
+ {%
+ unless if
+ %}
+ {% endcase %}
+ {% enddoc %}
+ LIQUID
+
+ assert_template_result('', template)
+ end
+
+ def test_doc_tag_ignores_unclosed_liquid_tags
+ template = <<~LIQUID.chomp
+ {% doc %}
+ {% if true %}
+ {% enddoc %}
+ LIQUID
+
+ assert_template_result('', template)
+ end
+
+ def test_doc_tag_does_not_allow_nested_docs
+ error = assert_raises(Liquid::SyntaxError) do
+ template = <<~LIQUID.chomp
+ {% doc %}
+ {% doc %}
+ {% doc %}
+ {% enddoc %}
+ LIQUID
+
+ Liquid::Template.parse(template)
+ end
+
+ exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed"
+ act_error = error.message
+
+ assert_equal(exp_error, act_error)
+ end
+
+ def test_doc_tag_ignores_nested_raw_tags
+ template = <<~LIQUID.chomp
+ {% doc %}
+ {% raw %}
+ {% enddoc %}
+ LIQUID
+
+ assert_template_result('', template)
+ end
+
+ def test_doc_tag_ignores_unclosed_assign
+ template = <<~LIQUID.chomp
+ {% doc %}
+ {% assign foo = "1"
+ {% enddoc %}
+ LIQUID
+
+ assert_template_result('', template)
+ end
+
+ def test_doc_tag_ignores_malformed_syntax
+ template = <<~LIQUID.chomp
+ {% doc %}
+ {% {{ {%- enddoc %}
+ LIQUID
+
+ assert_template_result('', template)
+ end
+
+ def test_doc_tag_captures_token_before_enddoc
+ template_source = "{% doc %}{{ incomplete{% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s)
+ end
+
+ def test_doc_tag_preserves_error_line_numbers
+ template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
+ {% doc %}
+ {% if true %}
+ {% enddoc %}
+ {{ errors.standard_error }}
+ LIQUID
+
+ expected = <<~TEXT.chomp
+
+ Liquid error (line 4): standard error
+ TEXT
+
+ assert_equal(expected, template.render('errors' => ErrorDrop.new))
+ end
+
+ def test_doc_tag_whitespace_control
+ # Basic whitespace control
+ assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!")
+ assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!")
+ assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!")
+ assert_template_result("Hello!", <<~LIQUID.chomp)
+ {%- doc %}Whitespace control!{% enddoc -%}
+ Hello!
+ LIQUID
+ end
+
+ def test_doc_tag_delimiter_handling
+ assert_template_result('', <<~LIQUID.chomp)
+ {%- if true -%}
+ {%- doc -%}
+ {%- docEXTRA -%}wut{% enddocEXTRA -%}xyz
+ {%- enddoc -%}
+ {%- endif -%}
+ LIQUID
+
+ assert_template_result('', "{% doc %}123{% enddoc xyz %}")
+ assert_template_result('', "{% doc %}123{% enddoc\txyz %}")
+ assert_template_result('', "{% doc %}123{% enddoc\nxyz %}")
+ assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}")
+ end
+
+ def test_doc_tag_visitor
+ template_source = '{% doc %}{% enddoc %}'
+
+ assert_equal(
+ [Liquid::Doc],
+ visit(template_source),
+ )
+ end
+
+ def test_doc_tag_blank_with_empty_content
+ template_source = "{% doc %}{% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal(true, doc_tag.blank?)
+ end
+
+ def test_doc_tag_blank_with_content
+ template_source = "{% doc %}Some documentation{% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal(false, doc_tag.blank?)
+ end
+
+ def test_doc_tag_blank_with_whitespace_only
+ template_source = "{% doc %} {% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal(false, doc_tag.blank?)
+ end
+
+ def test_doc_tag_nodelist_returns_array_with_body
+ doc_content = "Documentation content\n@param {string} foo"
+ template_source = "{% doc %}#{doc_content}{% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal([doc_content], doc_tag.nodelist)
+ assert_equal(1, doc_tag.nodelist.length)
+ assert_equal(doc_content, doc_tag.nodelist.first)
+ end
+
+ def test_doc_tag_nodelist_with_empty_content
+ template_source = "{% doc %}{% enddoc %}"
+
+ doc_tag = nil
+ ParseTreeVisitor
+ .for(Template.parse(template_source).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ doc_tag = tag
+ end
+ .visit
+
+ assert_equal([""], doc_tag.nodelist)
+ assert_equal(1, doc_tag.nodelist.length)
+ end
+
+ private
+
+ def traversal(template)
+ ParseTreeVisitor
+ .for(Template.parse(template).root)
+ .add_callback_for(Liquid::Doc) do |tag|
+ tag_class = tag.class
+ tag_class
+ end
+ end
+
+ def visit(template)
+ traversal(template).visit.flatten.compact
+ end
+end
diff --git a/test/unit/template_unit_test.rb b/test/unit/template_unit_test.rb
index b75669470..1f349657a 100644
--- a/test/unit/template_unit_test.rb
+++ b/test/unit/template_unit_test.rb
@@ -20,62 +20,13 @@ def test_sets_default_localization_in_context_with_quick_initialization
assert_equal(fixture("en_locale.yml"), locale.path)
end
- def test_with_cache_classes_tags_returns_the_same_class
- original_cache_setting = Liquid.cache_classes
- Liquid.cache_classes = true
-
- original_klass = Class.new
- Object.send(:const_set, :CustomTag, original_klass)
- Template.register_tag('custom', CustomTag)
-
- Object.send(:remove_const, :CustomTag)
-
- new_klass = Class.new
- Object.send(:const_set, :CustomTag, new_klass)
-
- assert(Template.tags['custom'].equal?(original_klass))
- ensure
- Object.send(:remove_const, :CustomTag)
- Template.tags.delete('custom')
- Liquid.cache_classes = original_cache_setting
- end
-
- def test_without_cache_classes_tags_reloads_the_class
- original_cache_setting = Liquid.cache_classes
- Liquid.cache_classes = false
-
- original_klass = Class.new
- Object.send(:const_set, :CustomTag, original_klass)
- Template.register_tag('custom', CustomTag)
-
- Object.send(:remove_const, :CustomTag)
-
- new_klass = Class.new
- Object.send(:const_set, :CustomTag, new_klass)
-
- assert(Template.tags['custom'].equal?(new_klass))
- ensure
- Object.send(:remove_const, :CustomTag)
- Template.tags.delete('custom')
- Liquid.cache_classes = original_cache_setting
- end
-
class FakeTag; end
- def test_tags_delete
- Template.register_tag('fake', FakeTag)
- assert_equal(FakeTag, Template.tags['fake'])
-
- Template.tags.delete('fake')
- assert_nil(Template.tags['fake'])
- end
-
def test_tags_can_be_looped_over
- Template.register_tag('fake', FakeTag)
- result = Template.tags.map { |name, klass| [name, klass] }
- assert(result.include?(["fake", "TemplateUnitTest::FakeTag"]))
- ensure
- Template.tags.delete('fake')
+ with_custom_tag('fake', FakeTag) do
+ result = Template.tags.map { |name, klass| [name, klass] }
+ assert(result.include?(["fake", TemplateUnitTest::FakeTag]))
+ end
end
class TemplateSubclass < Liquid::Template
@@ -84,4 +35,15 @@ class TemplateSubclass < Liquid::Template
def test_template_inheritance
assert_equal("foo", TemplateSubclass.parse("foo").render)
end
+
+ def test_invalid_utf8
+ input = "\xff\x00"
+ error = assert_raises(SyntaxError) do
+ Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input))
+ end
+ assert_equal(
+ 'Liquid syntax error: Invalid byte sequence in UTF-8',
+ error.message,
+ )
+ end
end
diff --git a/test/unit/tokenizer_unit_test.rb b/test/unit/tokenizer_unit_test.rb
index 6e8557242..76d379d36 100644
--- a/test/unit/tokenizer_unit_test.rb
+++ b/test/unit/tokenizer_unit_test.rb
@@ -6,6 +6,7 @@ class TokenizerTest < Minitest::Test
def test_tokenize_strings
assert_equal([' '], tokenize(' '))
assert_equal(['hello world'], tokenize('hello world'))
+ assert_equal(['{}'], tokenize('{}'))
end
def test_tokenize_variables
@@ -30,6 +31,23 @@ def test_calculate_line_numbers_per_token_with_profiling
assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} "))
end
+ def test_tokenize_with_nil_source_returns_empty_array
+ assert_equal([], tokenize(nil))
+ end
+
+ def test_incomplete_curly_braces
+ assert_equal(["{{.}", " "], tokenize('{{.} '))
+ assert_equal(["{{}", "%}"], tokenize('{{}%}'))
+ assert_equal(["{{}}", "}"], tokenize('{{}}}'))
+ end
+
+ def test_unmatching_start_and_end
+ assert_equal(["{{%}"], tokenize('{{%}'))
+ assert_equal(["{{%%%}}"], tokenize('{{%%%}}'))
+ assert_equal(["{%", "}}"], tokenize('{%}}'))
+ assert_equal(["{%%}", "}"], tokenize('{%%}}'))
+ end
+
private
def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil)
diff --git a/test/unit/variable_unit_test.rb b/test/unit/variable_unit_test.rb
index 2cc42e7d3..03f5862aa 100644
--- a/test/unit/variable_unit_test.rb
+++ b/test/unit/variable_unit_test.rb
@@ -108,7 +108,7 @@ def test_dashes
assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name)
assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name)
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
@@ -135,16 +135,68 @@ def test_lax_filter_argument_parsing
var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax)
assert_equal(VariableLookup.new('number_of_comments'), var.name)
assert_equal([['pluralize', ['comment', 'comments']]], var.filters)
+
+ # missing does not throws error
+ create_variable(%(n | f1: ,), error_mode: :lax)
+ create_variable(%(n | f1: ,| f2), error_mode: :lax)
+
+ # arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':'
+ var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax)
+ assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters)
+
+ # positional and kwargs parsing
+ var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax)
+ assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
+
+ # positional and kwargs intermixed (pos1, key1: val1, pos2)
+ var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax)
+ assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
end
def test_strict_filter_argument_parsing
- with_error_mode(:strict) do
+ with_error_modes(:strict) do
assert_raises(SyntaxError) do
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
end
end
end
+ def test_strict2_filter_argument_parsing
+ with_error_modes(:strict2) do
+ # optional colon
+ var = create_variable(%(n | f1 | f2:))
+ assert_equal([['f1', []], ['f2', []]], var.filters)
+
+ # missing argument throws error
+ assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
+ assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
+
+ # arg requires colon
+ assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
+
+ # trailing comma doesn't throw
+ create_variable(%(n | f1: 1, 2, 3, | f2:))
+
+ # missing comma throws error
+ assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
+
+ # positional and kwargs parsing
+ var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
+ assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
+
+ # positional and kwargs mixed
+ var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
+ assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
+
+ # positional and kwargs intermixed (pos1, key1: val1, pos2)
+ var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
+ assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
+
+ # string key throws
+ assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
+ end
+ end
+
def test_output_raw_source_of_variable
var = create_variable(%( name_of_variable | upcase ))
assert_equal(" name_of_variable | upcase ", var.raw)