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 @@ -[![Build Status](https://api.travis-ci.org/Shopify/liquid.svg?branch=master)](http://travis-ci.org/Shopify/liquid) +[![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml) [![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](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 `` and `
` 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("\n12\n12\n12\n", + assert_template_result( + "\n12\n12\n12\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 + + 123 + 456 + 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 + + 123 + 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 + + 345 + 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 + + 123 + 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 + + 23 + 45 + 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 + + 12 + 34 + 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 + + 12 + 34 + 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 + + 1020 + 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 + + 123 + 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 + + 12345 + 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( + "\n2:1:9982: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( + "\n1020\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( + "\n1020\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( + "\n123\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)