Compare commits

..
Author SHA1 Message Date
Chris Pak e7373023d6 Add targeted tests for byte-walking fast paths
36 tests covering the three optimization sites:

Expression.parse_number (13 tests):
  - Simple integers, negatives, floats, trailing dots
  - Multi-dot truncation (1.2.3 → 1.2)
  - Rejection of non-numeric input and trailing alpha (1.2.3a)

Expression.parse strip guard (7 tests):
  - Leading, trailing, both-sides whitespace
  - No-strip-needed case (no allocation)
  - Null byte stripping (matches String#strip behavior)

VariableLookup.simple_lookup? (8 tests):
  - Accepts: single names, dotted chains, question marks, hyphens
  - Rejects: brackets, empty, leading/trailing dots, double dots

VariableLookup fast path equivalence (7 tests):
  - name/lookups/command_flags match for simple and deep chains
  - Bracket inputs fall through to regex path correctly

BlockBody.try_parse_tag_token (10 tests):
  - Simple tags, whitespace control variants ({%-, -%}, both)
  - No-markup tags, hash comments, newline counting
  - Hyphenated names stop at hyphen (matching TagName = /\w+/)
  - Malformed tokens return nil (fallback to FullToken regex)
2026-04-05 21:01:31 -07:00
Chris Pak d3e39525f3 BlockBody: byte-walk tag tokens instead of FullToken regex
Add try_parse_tag_token that parses {%...%} tag tokens using
getbyte/byteslice + ByteTables lookup arrays instead of the
FullToken regex with 4 capture groups. Allocates only the 2
strings needed (tag_name, markup) vs 4+ from regex captures.

Uses ByteTables::WORD (no hyphen) for tag name scanning,
matching TagName = /#|\w+/ exactly. Falls back to FullToken
regex when the fast path returns nil.
2026-04-05 20:53:53 -07:00
Chris Pak fd853a3593 VariableLookup: fast path for simple identifier chains
Skip the expensive recursive VariableParser regex for simple
lookups like 'product.title' (~90% of real-world cases).

SIMPLE_LOOKUP_RE validates the input is a plain a.b.c chain
(no brackets, no quotes). On match, byte-walks on dots to split
segments instead of invoking the regex engine. Falls through to
the original VariableParser scan for complex inputs.
2026-04-05 20:53:53 -07:00
Chris Pak aaabfc5017 Expression: byte-walk parse_number and guard strip allocation
parse_number: replace INTEGER_REGEX/FLOAT_REGEX matching and
StringScanner loop with a single byte-walking pass using
ByteTables::DIGIT. Avoids MatchData allocation and StringScanner
reset on every call.

Expression.parse: only call String#strip when leading/trailing
whitespace is actually present (checked via ByteTables::WHITESPACE).
Avoids allocating a new String on ~4,464 calls per compile.
2026-04-05 20:53:53 -07:00
Chris Pak 7cd8df6fa8 Add ByteTables module and bench_quick.rb benchmark harness
ByteTables provides pre-computed 256-entry boolean lookup arrays for
byte classification (IDENT_START, IDENT_CONT, WORD, DIGIT, WHITESPACE)
and named constants for delimiter bytes (NEWLINE, DASH, DOT, HASH).

bench_quick.rb measures parse µs, render µs, and object allocations
for the theme benchmark suite.
2026-04-05 20:53:53 -07:00
Kevin MenardandGitHub a9c85622dd Merge pull request #2066 from eregon/skip-slow-test
Skip slow test raising many exceptions on non-CRuby
2026-03-26 02:14:10 -04:00
Benoit Daloze 9f4d7e78b8 Skip slow test raising many exceptions on non-CRuby 2026-03-25 17:20:30 +01:00
Kevin MenardandGitHub fd68d076dd Merge pull request #2038 from eregon/truffleruby-ci
Add TruffleRuby in CI
2026-03-25 11:53:01 -04:00
Benoit Daloze 96aa47d13f Add TruffleRuby in CI 2026-03-25 16:46:47 +01:00
Benoit Daloze ad70c5c459 Improve no Symbol leak tests to be more reliable 2026-03-25 16:46:47 +01:00
Benoit Daloze d824de701c Adapt slice filter to work on non-64 bit platforms 2026-03-25 16:46:47 +01:00
Alok SwamyandGitHub dd37353cca Merge pull request #2062 from Shopify/update-setup-ruby
Update ruby/setup-ruby to v1.295.0 for ubuntu-24.04 support
2026-03-19 15:29:47 -04:00
Alok SwamyandClaude Opus 4.6 e80f775f89 Update ruby/setup-ruby from v1.273.0 to v1.295.0
The pinned version (v1.273.0) does not have prebuilt `ruby-head` binaries
for `ubuntu-24.04`, which `ubuntu-latest` now resolves to. This causes CI
to fail with "Unavailable version head for ruby on ubuntu-24.04".

Updating to v1.295.0 picks up ubuntu-24.04 support for all Ruby versions
including head builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 15:22:56 -04:00
Ian Ker-SeymerandGitHub 59d8d0d22d Add cumulative resource score tracking across partial renders (#2058)
* feat: add cumulative resource score tracking across partial renders

Add cumulative_render_score and cumulative_assign_score counters to
ResourceLimits that accumulate across reset() calls, with optional
cumulative_render_score_limit and cumulative_assign_score_limit to
cap total work across all partial renders.

Also add a reached? check in BlockBody's render loop so that once a
cumulative limit triggers, the parent template stops processing
further nodes.

Bump version to 5.12.0.

* refactor: move cumulative limit enforcement into reset()

Instead of checking reached? in BlockBody's render loop, enforce
cumulative limits in reset() itself. Since reset() is called before
the begin/rescue MemoryError block in Template#render, the raise
propagates to the parent naturally — no changes to BlockBody needed.
2026-03-18 11:54:46 -04:00
Gray GilmoreandGitHub 5fa36267aa Merge pull request #2054 from Shopify/gg-fix-rubocop-offenses
Fix rubocop offenses in test file
2026-03-06 13:28:54 -08:00
Gray Gilmore a72b604680 Fix rubocop offenses in test file 2026-03-06 13:27:05 -08:00
Gray GilmoreandGitHub 3e76244cd2 Merge pull request #2050 from bakura10/squish-filter
Add squish filter
2026-03-06 13:22:18 -08:00
Michaël Gallego d589c51697 Add squish filter 2026-02-19 10:03:52 +09:00
CP ClermontandGitHub d897899f66 Merge pull request #2036 from Shopify/cp-fix-rubocop
Update the specs to new signature and fix CI
2026-01-14 09:14:01 -05:00
Charles-P. ClermontandClaude Opus 4.5 aa817c4cfd Update liquid-spec adapters for new ctx-based API
liquid-spec main changed the adapter API:
- compile block now receives (ctx, source, options) and should store
  the template in ctx[:template]
- render block now receives (ctx, assigns, options) and retrieves
  the template from ctx[:template]

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-13 13:06:55 -05:00
Charles-P. Clermont 7d90b524ea Remove on pull_request trigger. It's redundant. 2026-01-12 13:27:53 -05:00
Charles-P. Clermont bbcf8d6ad8 Better matrix CI check names 2026-01-12 13:27:52 -05:00
Charles-P. Clermont 51ff08db7b Fix CI 2026-01-12 13:06:13 -05:00
Tobi LutkeandClaude Opus 4.5 eaa9f215bf Add lax and YJIT liquid-spec adapters
- ruby_liquid_lax.rb: Tests lax parsing mode with :lax_parsing feature
- ruby_liquid_yjit.rb: Tests YJIT + strict mode + ActiveSupport

Matrix results: 4483 matched, 18 different (lax edge cases), 61 skipped

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-05 15:11:29 -10:00
Tobi LutkeandClaude Opus 4.5 ccd05e869c Make blank/empty comparisons invariant to ActiveSupport
- Implement liquid_blank? and liquid_empty? methods in Condition
  to emulate ActiveSupport's behavior when it's not loaded
- This ensures templates like `{% if x == blank %}` work identically
  whether ActiveSupport is loaded or not
- Update liquid-spec adapters for new API (ctx parameter)
- Add rake spec task for running liquid-spec matrix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-05 14:54:43 -10:00
Tobias LütkeandGitHub a4a29f3e08 Merge pull request #2034 from Shopify/fix-liquid-spec-without-activesupport
Require liquid-spec to be run on commit automatically and related fixes
2026-01-01 22:32:38 -05:00
Tobi Lutke 0058e4322b Add fail-fast: false to prevent job cancellation 2026-01-01 22:30:14 -05:00
Tobi Lutke 50e1789537 Use liquid-spec feature branch until PR is merged 2026-01-01 22:29:32 -05:00
Tobi Lutke 79a2e042ff Use liquid-spec main branch 2026-01-01 22:24:00 -05:00
Tobi Lutke ddee08fb95 Add activesupport feature to with_active_support adapter
Both adapters now pass with 0 failures:
- ruby_liquid.rb: 4194 passed (skips activesupport and shopify_error_handling specs)
- ruby_liquid_with_active_support.rb: 4203 passed (skips shopify_error_handling specs)
2026-01-01 22:21:47 -05:00
Tobi Lutke 2988f1a500 Update liquid-spec to branch with per-spec required_features support 2026-01-01 22:15:38 -05:00
Tobi Lutke ef13b2dfd5 Fix empty? semantics and string first/last for empty strings
- nil is NOT empty (but IS blank) - matches Shopify production
- String first/last returns '' for empty strings, not nil - matches ActiveSupport
- Add test for nil not being empty
2026-01-01 22:06:22 -05:00
Tobi Lutke b0fb0ad83f Run liquid-spec for all adapters in spec/*.rb 2026-01-01 22:02:42 -05:00
Tobi Lutke ae26cb29ac Disable auto-require for activesupport gem 2026-01-01 22:00:49 -05:00
Tobi Lutke 608a877053 Add spec adapter with ActiveSupport for comparison testing 2026-01-01 22:00:38 -05:00
Tobi Lutke d321adae77 Fix spec adapter for liquid-spec API (template, assigns, options) 2026-01-01 21:59:16 -05:00
Tobi Lutke 53641e19ce Pin liquid-spec to minimum required commit 3d1b492 2026-01-01 21:57:34 -05:00
Tobi Lutke ccd10a986a Pin liquid-spec to main branch 2026-01-01 21:56:39 -05:00
Tobi Lutke f4890de9d5 hm 2026-01-01 21:54:08 -05:00
Tobi Lutke 7e3ccbc188 test 2026-01-01 21:49:33 -05:00
Tobi Lutke e0b46049af Add Ruby 3.4 yjit, 4.0 zjit, and head zjit to CI matrix 2026-01-01 21:44:28 -05:00
Tobi Lutke 19528a9b3f Update CI matrix: remove Ruby 3.0-3.2, add Ruby 4.0 2026-01-01 21:42:47 -05:00
Tobi Lutke 34c274d314 Fix rubocop: rename ruby-liquid.rb to ruby_liquid.rb and add trailing comma 2026-01-01 21:42:11 -05:00
Tobi Lutke 533d470723 Fix spec job to include :spec bundle group 2026-01-01 21:35:13 -05:00
Tobi Lutke 05f9c2a030 Add liquid-spec for conformance testing
- Add liquid-spec gem from GitHub to :spec group
- Create spec/ruby-liquid.rb adapter for the reference implementation
- Add spec job to CI workflow to run liquid-spec tests
2026-01-01 21:34:10 -05:00
Tobi Lutke af58800c16 Update rubocop-shopify to 2.18.0 and fix new offenses 2026-01-01 20:22:19 -05:00
Tobi Lutke 361d1d52b1 Fix rubocop offenses from 1.82 upgrade 2026-01-01 20:20:50 -05:00
Tobi Lutke 391c0df57a Update rubocop to 1.82.0 for Ruby 4.0 support 2026-01-01 20:19:21 -05:00
Tobi Lutke 0ed29760c0 Add benchmark gem for Ruby 4.0 compatibility 2026-01-01 20:18:35 -05:00
Tobi Lutke 0e3548d39e Remove redundant else-clause 2026-01-01 20:16:24 -05:00
Tobi Lutke 33bac87a5c Address liquid-spec issues without ActiveSupport loaded
Implement ActiveSupport-compatible behaviors internally so Liquid works
correctly without ActiveSupport being loaded:

1. String first/last via property access (name.first, name.last)
   - VariableLookup now handles string[0] and string[-1] for first/last

2. String first/last via filters (name | first, name | last)
   - StandardFilters#first and #last now handle strings

3. blank?/empty? comparisons for types without these methods
   - Condition now implements liquid_blank? and liquid_empty? internally
   - blank? matches ActiveSupport: nil, false, empty/whitespace strings,
     empty arrays/hashes are all blank
   - empty? checks length == 0 only (whitespace is NOT empty)

This fixes spec failures for templates like:
- {{ name.first }} / {{ name | first }} on strings
- {% if x == blank %} for whitespace strings, empty hashes/arrays
- {% case ' ' %}{% when blank %} matching whitespace
2026-01-01 20:14:21 -05:00
CP ClermontandGitHub a60a6c0d93 Merge pull request #2027 from Shopify/cp-make-new-tests-serializable-to-liquid-spec
Make new tests serializable to liquid-spec
2025-12-18 15:41:04 -05:00
Charles-P. Clermont bad29caaae Fixup GH action 2025-12-18 15:39:13 -05:00
Charles-P. Clermont cbeff64708 Make new tests serializable to liquid-spec
(No anonymous classes)
2025-12-18 13:56:02 -05:00
Ian Ker-SeymerandGitHub 22e979a6fa Use floating-point format for BigDecimal stringification (#2022)
## Summary

- BigDecimal values now stringify using `to_s("F")` format instead of the default `to_s`

## Why

Ruby's default `BigDecimal#to_s` produces engineering/scientific notation for certain values:

```ruby
BigDecimal("0.00001").to_s      # => "0.1E-4"
BigDecimal("12345678.9").to_s   # => "0.123456789E8"
```

This is rarely the desired output in templates. Using `to_s("F")` produces the expected floating-point format:

```ruby
BigDecimal("0.00001").to_s("F")      # => "0.00001"
BigDecimal("12345678.9").to_s("F")   # => "12345678.9"
```
2025-12-05 15:54:52 -05:00
JuliaandGitHub 735d551168 Merge pull request #2016 from Shopify/jb-method-literals
Preserve literal semantics in strict2 case/when
2025-12-04 09:11:58 -07:00
Julia Boutin fa27bfe6e0 Preserve literal semantics in strict2 case/when
Previously, strict2 case/when used `safe_parse_expression`
to parse when expressions causing `blank`/`empty` to be
treated as string literals (Expression::LITERALS maps 'empty' => ''),
rather than method literals

This caused unexpected behavior:

```
{%- case empty_obj -%}
{%- when empty -%}
  previously: doesn't render (empty_obj == '' is false)
  now: renders (empty_obj.empty? is true)
{%- endcase -%}
```

This commit instead calls `Condition.parse_expression`
with `safe: true`, which will correctly handle `blank`
and `empty`
2025-11-28 15:51:48 -07:00
Guilherme CarreiroandGitHub 32b50ecafe Bump Liquid to 5.11.0 (#2012)
This commit reverts the Inline Snippets tag (#2001) and bumps
Liquid to 5.11. For now, the inclusion of Inline Snippets
in the latest Liquid release is being treated as a bug.

While #2001 does implement the scope contained in RFC#1916,
we need to take a step back to make sure we’re setting our
sights high enough with this feature, and that we’re truly
supporting theme developers in the ways they need.

If you have any feedback, please leave a comment on RFC#1916.

- Liquid Developer Tools
2025-11-19 18:03:23 +01:00
Gray GilmoreandGitHub cbd8a0a2ee Merge pull request #2007 from Shopify/gg-change-key-behavior
Don't raise if no variable found when `using context.key?` with `strict_variables`
2025-11-04 13:01:20 -08:00
Gray Gilmore 9973f3399e Don't raise if no variable found when using context.key?
Previously if you set `strict_variables` to `true` on the context using
`key?('key_name')` would raise a `Liquid::UndefinedVariable` error.
Raising this error makes sense if you're trying to access the variable
directly with something like  `context['key_name']` but by using `key?`
you're safely checking if it exists first.

You should be able to enable `strict_variables` and use `key?` in
combination with each other to ensure code safety.
2025-10-30 15:33:07 -07:00
63 changed files with 1538 additions and 1476 deletions
+38 -17
View File
@@ -1,5 +1,5 @@
name: Liquid
on: [push, pull_request]
on: [push]
env:
BUNDLE_JOBS: 4
@@ -9,30 +9,34 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
entry:
- { ruby: 3.0, allowed-failure: false } # minimum supported
- { ruby: 3.2, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false }
- { ruby: 3.4, allowed-failure: false } # latest
- {
ruby: 3.4,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 3.3, allowed-failure: false } # minimum supported
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: ruby-head, allowed-failure: false }
- { ruby: 4.0, allowed-failure: false } # latest stable
- {
ruby: ruby-head,
ruby: 4.0,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
name: Test Ruby ${{ matrix.entry.ruby }}
- { 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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
@@ -42,11 +46,28 @@ jobs:
env:
RUBYOPT: ${{ matrix.entry.rubyopt }}
spec:
runs-on: ubuntu-latest
env:
BUNDLE_WITH: spec
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
bundler-cache: true
bundler: latest
- name: Run liquid-spec for all adapters
run: |
for adapter in spec/*.rb; do
echo "=== Running $adapter ==="
bundle exec liquid-spec run "$adapter" --no-max-failures
done
memory_profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
bundler-cache: true
- run: bundle exec rake memory_profile:run
+10 -1
View File
@@ -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'
+8 -2
View File
@@ -25,7 +25,13 @@ group :development do
end
group :test do
gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'benchmark'
gem 'rubocop', '~> 1.82.0'
gem 'rubocop-shopify', '~> 2.18.0', require: false
gem 'rubocop-performance', require: false
end
group :spec do
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main'
gem 'activesupport', require: false
end
+4 -6
View File
@@ -1,13 +1,11 @@
# Liquid Change Log
## 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]
```
{%- snippet snowdevil -%}
Snowdevil
{%- endsnippet -%}
{% render snowdevil %}
```
## 5.9.0
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]
+4 -4
View File
@@ -103,10 +103,10 @@ Liquid also comes with different parsers that can be used when editing templates
when templates are invalid. You can enable this new parser like this:
```ruby
Liquid::Environment.default.error_mode = :rigid # Raises a SyntaxError when invalid syntax is used in all tags
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
Liquid::Environment.default.error_mode = :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`:
+14 -8
View File
@@ -33,7 +33,7 @@ task :rubocop do
end
end
desc('runs test suite with lax, strict, and rigid 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,7 +42,7 @@ task :test do
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'rigid'
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
@@ -55,7 +55,7 @@ task :test do
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'rigid'
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
@@ -88,13 +88,13 @@ namespace :benchmark do
ruby "./performance/benchmark.rb strict"
end
desc "Run the liquid benchmark with rigid parsing"
task :rigid do
ruby "./performance/benchmark.rb rigid"
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 rigid parsing"
task run: [:lax, :strict, :rigid]
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
task run: [:lax, :strict, :strict2]
desc "Run unit benchmarks"
namespace :unit do
@@ -148,3 +148,9 @@ end
task :console do
exec 'irb -I lib -r liquid'
end
desc('run liquid-spec suite across all adapters')
task :spec do
adapters = Dir['./spec/*.rb'].join(',')
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
end
+1 -1
View File
@@ -41,6 +41,6 @@ def assigns
end
puts Liquid::Template
.parse(source, error_mode: :rigid)
.parse(source, error_mode: :strict2)
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
.render(assigns)
+1 -1
View File
@@ -52,6 +52,7 @@ end
require "liquid/version"
require "liquid/deprecations"
require "liquid/const"
require "liquid/byte_tables"
require 'liquid/standardfilters'
require 'liquid/file_system'
require 'liquid/parser_switching'
@@ -67,7 +68,6 @@ require 'liquid/i18n'
require 'liquid/drop'
require 'liquid/tablerowloop_drop'
require 'liquid/forloop_drop'
require 'liquid/snippet_drop'
require 'liquid/extensions'
require 'liquid/errors'
require 'liquid/interrupts'
+83 -7
View File
@@ -130,16 +130,21 @@ module Liquid
case
when token.start_with?(TAGSTART)
whitespace_handler(token, parse_context)
unless token =~ FullToken
# rubocop:disable Metrics/BlockNesting
fast = try_parse_tag_token(token)
if fast
tag_name, markup, newlines = fast
elsif token =~ FullToken
tag_name = Regexp.last_match(2)
markup = Regexp.last_match(4)
newlines = parse_context.line_number ? Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n") : 0
else
return handle_invalid_tag_token(token, parse_context, &block)
end
tag_name = Regexp.last_match(2)
markup = Regexp.last_match(4)
# rubocop:enable Metrics/BlockNesting
if parse_context.line_number
# newlines inside the tag should increase the line number,
# particularly important for multiline {% liquid %} tags
parse_context.line_number += Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n")
if parse_context.line_number && newlines > 0
parse_context.line_number += newlines
end
if tag_name == 'liquid'
@@ -260,6 +265,77 @@ module Liquid
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
# Fast path for parsing "{%[-] tag_name markup [-]%}" tag tokens.
# Returns [tag_name, markup, newline_count] or nil.
#
# Accepts tokens where:
# - Tag name is '#' or starts with [a-zA-Z_] followed by \w chars
# (matching TagName = /#|\w+/ exactly — no hyphens, no '?' suffix)
# - Whitespace is spaces, tabs, newlines, \r, \f, \v
# - Whitespace control dashes are at positions 2 and len-3
# Rejects (returns nil → caller falls back to FullToken regex):
# - Tokens shorter than "{%x%}" (4 bytes)
# - Tag names starting with a digit (valid in FullToken but rare)
# - Any structure the byte-walk can't confidently parse
# Fallback: nil return triggers the original `token =~ FullToken` regex
# match in parse_for_document, preserving identical behavior for any
# input the fast path doesn't handle.
def try_parse_tag_token(token)
len = token.bytesize
pos = 2 # skip "{%"
return if pos >= len
pos += 1 if token.getbyte(pos) == ByteTables::DASH
newline_count = 0
# Skip whitespace before tag name, count newlines
while pos < len
b = token.getbyte(pos)
if b == ByteTables::NEWLINE
pos += 1
newline_count += 1
elsif ByteTables::WHITESPACE[b]
pos += 1
else
break
end
end
return if pos >= len
# Scan tag name: '#' or \w+ (matching TagName = /#|\w+/)
name_start = pos
b = token.getbyte(pos)
if b == ByteTables::HASH
pos += 1
elsif ByteTables::IDENT_START[b]
pos += 1
pos += 1 while pos < len && ByteTables::WORD[token.getbyte(pos)]
else
return
end
tag_name = token.byteslice(name_start, pos - name_start)
# Skip whitespace after tag name, count newlines
while pos < len
b = token.getbyte(pos)
if b == ByteTables::NEWLINE
pos += 1
newline_count += 1
elsif ByteTables::WHITESPACE[b]
pos += 1
else
break
end
end
# Markup: everything up to optional '-' before '%}'
markup_end = len - 2 # skip '%}'
markup_end -= 1 if markup_end > pos && token.getbyte(markup_end - 1) == ByteTables::DASH
markup = pos >= markup_end ? "" : token.byteslice(pos, markup_end - pos)
[tag_name, markup, newline_count]
end
# @deprecated Use {.raise_missing_tag_terminator} instead
def raise_missing_tag_terminator(token, parse_context)
BlockBody.raise_missing_tag_terminator(token, parse_context)
+48
View File
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module Liquid
# Pre-computed 256-entry boolean lookup tables for byte classification.
# Built once at load time; used as TABLE[byte] — a single array index
# instead of 3-5 comparison operators per check.
module ByteTables
# [a-zA-Z_] — valid first byte of an identifier
IDENT_START = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
t[95] = true # _
end.freeze
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
IDENT_CONT = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
(48..57).each { |b| t[b] = true } # 0-9
t[95] = true # _
t[45] = true # -
end.freeze
# [a-zA-Z0-9_] — \w equivalent (no hyphen), for tag name scanning
WORD = Array.new(256, false).tap do |t|
(97..122).each { |b| t[b] = true } # a-z
(65..90).each { |b| t[b] = true } # A-Z
(48..57).each { |b| t[b] = true } # 0-9
t[95] = true # _
end.freeze
# [0-9] — ASCII digit
DIGIT = Array.new(256, false).tap do |t|
(48..57).each { |b| t[b] = true }
end.freeze
# Matches bytes removed by Ruby's String#strip: \x00, \t, \n, \v, \f, \r, space
WHITESPACE = Array.new(256, false).tap do |t|
[0, 9, 10, 11, 12, 13, 32].each { |b| t[b] = true }
end.freeze
# Byte constants for delimiters and punctuation
NEWLINE = 10
DASH = 45 # '-'
DOT = 46 # '.'
HASH = 35 # '#'
end
end
+55 -12
View File
@@ -113,24 +113,67 @@ module Liquid
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)
else
return nil
end
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
false
end
end
end
# Implement blank? semantics matching ActiveSupport
# blank? returns true for nil, false, empty strings, whitespace-only strings,
# empty arrays, and empty hashes
def liquid_blank?(value)
case value
when NilClass, FalseClass
true
when TrueClass, Numeric
false
when String
# Blank if empty or whitespace only (matches ActiveSupport)
value.empty? || value.match?(/\A\s*\z/)
when Array, Hash
value.empty?
else
# Fall back to empty? if available, otherwise false
value.respond_to?(:empty?) ? value.empty? : false
end
end
# Implement empty? semantics
# Note: nil is NOT empty. empty? checks if a collection has zero elements.
def liquid_empty?(value)
case value
when String, Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : false
end
end
def interpret_condition(left, right, op, context)
# If the operator is empty this means that the decision statement is just
# a single variable. We can just poll this variable from the context and
@@ -154,8 +197,8 @@ module Liquid
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
+1 -1
View File
@@ -184,7 +184,7 @@ module Liquid
end
def key?(key)
self[key] != nil
find_variable(key, raise_on_not_found: false) != nil
end
def evaluate(object)
+1 -1
View File
@@ -31,7 +31,7 @@ module Liquid
# 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
+1 -1
View File
@@ -34,7 +34,7 @@ module Liquid
# @param file_system The default file system that is used
# to load templates from.
# @param error_mode [Symbol] The default error mode for all templates
# (either :rigid, :strict, :warn, or :lax).
# (either :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.
+74 -55
View File
@@ -16,16 +16,9 @@ module Liquid
'-' => VariableLookup.parse("-", nil).freeze,
}.freeze
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/
INTEGER_REGEX = /\A(-?\d+)\z/
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
class << self
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
@@ -35,7 +28,15 @@ module Liquid
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
markup = markup.strip # markup can be a frozen string
# Guard: only call .strip when the first or last byte is whitespace.
# String#strip always allocates a new String, even when there's nothing
# to strip. ByteTables::WHITESPACE matches the same bytes that strip
# removes (space, \t, \n, \v, \f, \r, \x00). When neither end has
# whitespace, we skip the call and avoid ~4,464 allocations per compile.
first = markup.getbyte(0)
if first && (ByteTables::WHITESPACE[first] || ByteTables::WHITESPACE[markup.getbyte(markup.bytesize - 1)])
markup = markup.strip
end
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
@@ -55,7 +56,7 @@ module Liquid
end
def inner_parse(markup, ss, cache)
if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX
return RangeLookup.parse(
Regexp.last_match(1),
Regexp.last_match(2),
@@ -71,56 +72,74 @@ module Liquid
end
end
def parse_number(markup, ss)
# check if the markup is simple integer or float
case markup
when INTEGER_REGEX
return Integer(markup, 10)
when FLOAT_REGEX
return markup.to_f
end
# Fast path for number parsing. Accepts:
# - Simple integers: "42", "-7"
# - Simple floats: "3.14", "-0.5"
# - Multi-dot floats (truncated at second dot): "1.2.3" → 1.2
# - Trailing-dot floats: "123." → 123.0
# Rejects (returns nil → caller treats as VariableLookup):
# - Non-numeric input: "hello", ""
# - Inputs with non-digit/non-dot bytes after the number: "1.2.3a"
# Fallback: nil return causes caller to fall through to VariableLookup.parse,
# which is the same path the old regex-based code took on non-match.
def parse_number(markup, _ss = nil)
len = markup.bytesize
return if len == 0
ss.string = markup
# the first byte must be a digit or a dash
byte = ss.scan_byte
pos = 0
first = markup.getbyte(pos)
return false if byte != DASH && (byte < ZERO || byte > NINE)
if first == ByteTables::DASH
pos += 1
return if pos >= len
return unless ByteTables::DIGIT[markup.getbyte(pos)]
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
pos += 1
elsif ByteTables::DIGIT[first]
pos += 1
else
# number ends with a dot "123."
markup.byteslice(0, first_dot_pos).to_f
return
end
# Scan digits
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
# Consumed everything = simple integer
return Integer(markup, 10) if pos == len
# Check for dot — three float cases:
# 1. Simple float: "123.456" → markup.to_f
# 2. Multi-dot: "1.2.3.4" → truncate at second dot → 1.2
# 3. Trailing dot: "123." → truncate before dot → 123.0
return unless markup.getbyte(pos) == ByteTables::DOT
dot_pos = pos
pos += 1
digit_start = pos
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
if pos > digit_start && pos == len
# Case 1: simple float like "123.456"
markup.to_f
elsif pos > digit_start
# Case 2: multi-dot like "1.2.3.4" — find where the numeric
# portion ends. Reject if any non-digit, non-dot byte is found
# (e.g. "1.2.3a" → nil, matching the old regex-based behavior).
num_end = nil
check = pos
while check < len
b = markup.getbyte(check)
if b == ByteTables::DOT
num_end ||= check
elsif !ByteTables::DIGIT[b]
return
end
check += 1
end
markup.byteslice(0, num_end || len).to_f
else
# Case 3: trailing dot like "123."
markup.byteslice(0, dot_pos).to_f
end
end
end
+1 -1
View File
@@ -28,7 +28,7 @@ module Liquid
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
-3
View File
@@ -5,7 +5,6 @@
block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}"
assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [var]"
case: "Syntax Error in 'case' - Valid syntax: case [condition]"
case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}"
case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) "
@@ -20,7 +19,6 @@
invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}"
invalid_template_encoding: "Invalid template encoding"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
render_invalid_template_name: "Syntax error in tag 'render' - Expected a string or identifier, found %{found}"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
tag_never_closed: "'%{block_name}' tag was never closed"
@@ -31,6 +29,5 @@
variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}"
argument:
include: "Argument error in tag 'include' - Illegal template name"
render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed"
disabled:
tag: "usage is not allowed in this context"
+4 -4
View File
@@ -55,15 +55,15 @@ module Liquid
end
def parse_expression(markup, safe: false)
if !safe && @error_mode == :rigid
if !safe && @error_mode == :strict2
# parse_expression is a widely used API. To maintain backward
# compatibility while raising awareness about rigid parser standards,
# compatibility while raising awareness about strict2 parser standards,
# the safe flag supports API users make a deliberate decision.
#
# In rigid mode, markup MUST come from a string returned by the parser
# 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 rigid mode"
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
end
Expression.parse(markup, @string_scanner, @expression_cache)
+18 -10
View File
@@ -7,16 +7,19 @@ module Liquid
# It's basically doing the same thing the {#parse_with_selected_parser},
# except this will try the strict parser regardless of the error mode,
# and fall back to the lax parser if the error mode is lax or warn,
# except when in rigid mode where it uses the rigid parser.
# 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 rigid_parse_with_error_context(markup) if rigid_mode?
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
@@ -28,12 +31,13 @@ module Liquid
def parse_with_selected_parser(markup)
case parse_context.error_mode
when :rigid then rigid_parse_with_error_context(markup)
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :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
rigid_parse_with_error_context(markup)
strict2_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
@@ -41,14 +45,18 @@ module Liquid
end
end
def rigid_mode?
parse_context.error_mode == :rigid
def strict2_mode?
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
end
private
def rigid_parse_with_error_context(markup)
rigid_parse(markup)
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)
+22 -5
View File
@@ -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 @@ module Liquid
@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
-22
View File
@@ -1,22 +0,0 @@
# frozen_string_literal: true
module Liquid
class SnippetDrop < Drop
attr_reader :body, :name, :filename
def initialize(body, name, filename)
super()
@body = body
@name = name
@filename = filename
end
def to_partial
@body
end
def to_s
'SnippetDrop'
end
end
end
+33 -7
View File
@@ -8,10 +8,19 @@ module Liquid
MAX_I32 = (1 << 31) - 1
private_constant :MAX_I32
MIN_I64 = -(1 << 63)
MAX_I64 = (1 << 63) - 1
I64_RANGE = MIN_I64..MAX_I64
private_constant :MIN_I64, :MAX_I64, :I64_RANGE
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 = {
'&' => '&amp;',
@@ -214,11 +223,11 @@ module Liquid
Utils.to_s(input).slice(offset, length) || ''
end
rescue RangeError
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
raise # unexpected error
end
offset = offset.clamp(I64_RANGE)
length = length.clamp(I64_RANGE)
offset = offset.clamp(INDEX_RANGE)
length = length.clamp(INDEX_RANGE)
retry
end
end
@@ -293,6 +302,19 @@ module Liquid
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
# @liquid_type filter
# @liquid_category string
@@ -768,6 +790,8 @@ module Liquid
# @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
@@ -779,6 +803,8 @@ module Liquid
# @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
-2
View File
@@ -20,7 +20,6 @@ require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
require_relative "tags/doc"
require_relative "tags/snippet"
module Liquid
module Tags
@@ -45,7 +44,6 @@ module Liquid
'echo' => Echo,
'tablerow' => TableRow,
'doc' => Doc,
'snippet' => Snippet,
}.freeze
end
end
+5 -5
View File
@@ -86,7 +86,7 @@ module Liquid
private
def rigid_parse(markup)
def strict2_parse(markup)
parser = @parse_context.new_parser(markup)
@left = safe_parse_expression(parser)
parser.consume(:end_of_string)
@@ -107,18 +107,18 @@ module Liquid
def record_when_condition(markup)
body = new_body
if rigid_mode?
parse_rigid_when(markup, body)
if strict2_mode?
parse_strict2_when(markup, body)
else
parse_lax_when(markup, body)
end
end
def parse_rigid_when(markup, body)
def parse_strict2_when(markup, body)
parser = @parse_context.new_parser(markup)
loop do
expr = safe_parse_expression(parser)
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
block = Condition.new(@left, '==', expr)
block.attach(body)
@blocks << block
+1 -1
View File
@@ -56,7 +56,7 @@ module Liquid
private
# cycle [name:] expression(, expression)*
def rigid_parse(markup)
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variables = []
+1 -1
View File
@@ -111,7 +111,7 @@ module Liquid
private
def rigid_parse(markup)
def strict2_parse(markup)
strict_parse(markup)
end
+1 -1
View File
@@ -66,7 +66,7 @@ module Liquid
private
def rigid_parse(markup)
def strict2_parse(markup)
strict_parse(markup)
end
+1 -1
View File
@@ -84,7 +84,7 @@ module Liquid
alias_method :parse_context, :options
private :parse_context
def rigid_parse(markup)
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = safe_parse_expression(p)
+17 -23
View File
@@ -27,7 +27,7 @@ module Liquid
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
class Render < Tag
FOR = 'for'
SYNTAX = /(#{QuotedString}+|#{VariableSegment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
disable_tags "include"
@@ -47,23 +47,21 @@ module Liquid
end
def render_tag(context, output)
template = context.evaluate(@template_name_expr)
# The expression should be a String literal, which parses to a String object
template_name = @template_name_expr
raise ::ArgumentError unless template_name.is_a?(String)
if @template_name_expr.is_a?(String)
partial = PartialCache.load(template, context: context, parse_context: parse_context)
template_name = partial.name
context_variable_name = @alias_name || template_name.split('/').last
elsif template.respond_to?(:to_partial) && template.respond_to?(:name)
partial = template.to_partial
template_name = template.filename
context_variable_name = @alias_name || template.name
else
raise ::ArgumentError
end
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context,
)
context_variable_name = @alias_name || template_name.split('/').last
render_partial_func = ->(var, forloop) {
inner_context = context.new_isolated_subcontext
inner_context.template_name = template_name
inner_context.template_name = partial.name
inner_context.partial = true
inner_context['forloop'] = forloop if forloop
@@ -87,10 +85,10 @@ module Liquid
end
# render (string) (with|for expression)? (as id)? (key: value)*
def rigid_parse(markup)
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = parse_expression(rigid_template_name(p), safe: true)
@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")
@@ -103,18 +101,14 @@ module Liquid
key = p.consume
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma) # optional comma
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def rigid_template_name(p)
return p.consume(:string) if p.look(:string)
return p.consume(:id) if p.look(:id)
found = p.consume || "nothing"
raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found)
def strict2_template_name(p)
p.consume(:string)
end
def strict_parse(markup)
-39
View File
@@ -1,39 +0,0 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category variable
# @liquid_name snippet
# @liquid_summary
# Creates a new inline snippet.
# @liquid_description
# You can create inline snippets to make your Liquid code more modular.
# @liquid_syntax
# {% snippet snippet_name %}
# value
# {% endsnippet %}
class Snippet < Block
def initialize(tag_name, markup, options)
super
p = @parse_context.new_parser(markup)
if p.look(:id)
@to = p.consume(:id)
p.consume(:end_of_string)
else
raise SyntaxError, options[:locale].t("errors.syntax.snippet")
end
end
def render_to_output_buffer(context, output)
snippet_drop = SnippetDrop.new(@body, @to, context.template_name)
context.scopes.last[@to] = snippet_drop
context.resource_limits.increment_assign_score(1)
output
end
def blank?
true
end
end
end
+1 -1
View File
@@ -34,7 +34,7 @@ module Liquid
parse_with_selected_parser(markup)
end
def rigid_parse(markup)
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
+1 -1
View File
@@ -25,7 +25,7 @@ module Liquid
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict enforces correct syntax for most tags
# :rigid enforces correct syntax for all tags
# :strict2 enforces correct syntax for all tags
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
+1 -1
View File
@@ -117,7 +117,7 @@ module Liquid
byte_a = byte_b = @ss.scan_byte
while byte_b
byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY)
byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY
break unless byte_a
+3 -1
View File
@@ -69,7 +69,7 @@ module Liquid
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
@@ -95,6 +95,8 @@ module Liquid
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
+3 -3
View File
@@ -74,14 +74,14 @@ module Liquid
p.consume(:end_of_string)
end
def rigid_parse(markup)
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 << rigid_parse_filter_expressions(p) while p.consume?(:pipe)
@filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
@@ -156,7 +156,7 @@ module Liquid
# argument = (positional_argument | keyword_argument)
# positional_argument = expression
# keyword_argument = id ":" expression
def rigid_parse_filter_expressions(p)
def strict2_parse_filter_expressions(p)
filtername = p.consume(:id)
filter_args = []
keyword_args = {}
+49
View File
@@ -4,6 +4,25 @@ module Liquid
class VariableLookup
COMMAND_METHODS = ['size', 'first', 'last'].freeze
# Matches simple identifier chains: name(.name)* with no brackets/quotes
SIMPLE_LOOKUP_RE = /\A[\w-]+\??(?:\.[\w-]+\??)*\z/
# Returns true when markup is a simple dotted identifier chain that the
# fast path in initialize can handle. Accepts:
# - Single names: "product", "item"
# - Dotted chains: "product.title", "cart.items.first"
# - Question-mark suffixes: "product.available?"
# - Hyphens in names: "my-var.some-field"
# Rejects (falls through to VariableParser regex):
# - Bracket lookups: "product[0]", "hash['key']"
# - Quoted strings, empty input, leading/trailing dots
# Fallback: when this returns false, initialize uses the original
# markup.scan(VariableParser) path — behavior is identical to
# the pre-optimization code for any input the fast path rejects.
def self.simple_lookup?(markup)
markup.bytesize > 0 && markup.match?(SIMPLE_LOOKUP_RE)
end
attr_reader :name, :lookups
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
@@ -11,6 +30,31 @@ module Liquid
end
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
if self.class.simple_lookup?(markup)
dot_pos = markup.index('.')
if dot_pos.nil?
@name = markup
@lookups = Const::EMPTY_ARRAY
@command_flags = 0
return
end
@name = markup.byteslice(0, dot_pos)
@lookups = []
@command_flags = 0
pos = dot_pos + 1
len = markup.bytesize
while pos < len
seg_start = pos
pos += 1 while pos < len && markup.getbyte(pos) != ByteTables::DOT
seg = markup.byteslice(seg_start, pos - seg_start)
@command_flags |= 1 << @lookups.length if COMMAND_METHODS.include?(seg)
@lookups << seg
pos += 1 # skip dot
end
return
end
lookups = markup.scan(VariableParser)
name = lookups.shift
@@ -70,6 +114,11 @@ module Liquid
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
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.10.0"
VERSION = "5.12.0"
end
+62
View File
@@ -0,0 +1,62 @@
# frozen_string_literal: true
# Quick benchmark: measures parse µs, render µs, and object allocations
# Outputs machine-readable metrics to stdout
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
runner = ThemeRunner.new
# Warmup — enough iterations for YJIT to fully optimize hot paths
20.times { runner.compile }
20.times { runner.render }
GC.start
GC.compact if GC.respond_to?(:compact)
# Measure parse
parse_times = []
10.times do
GC.disable
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
runner.compile
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
GC.enable
GC.start
parse_times << (t1 - t0) * 1_000_000 # µs
end
# Measure render
render_times = []
10.times do
GC.disable
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
runner.render
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
GC.enable
GC.start
render_times << (t1 - t0) * 1_000_000 # µs
end
# Measure object allocations for one parse+render cycle
require 'objspace'
GC.start
GC.disable
before = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
runner.compile
runner.render
after = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
GC.enable
allocations = after - before
parse_us = parse_times.min.round(0)
render_us = render_times.min.round(0)
combined_us = parse_us + render_us
puts "RESULTS"
puts "parse_us=#{parse_us}"
puts "render_us=#{render_us}"
puts "combined_us=#{combined_us}"
puts "allocations=#{allocations}"
+36
View File
@@ -0,0 +1,36 @@
# 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'
LiquidSpec.configure do |config|
# Run core Liquid specs
config.features = [:core]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
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],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+34
View File
@@ -0,0 +1,34 @@
# 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'
LiquidSpec.configure do |config|
config.features = [:core, :lax_parsing]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force lax mode
options = options.merge(error_mode: :lax)
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],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+37
View File
@@ -0,0 +1,37 @@
# 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'
LiquidSpec.configure do |config|
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
config.features = [:core, :activesupport]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
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],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+41
View File
@@ -0,0 +1,41 @@
# 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'
LiquidSpec.configure do |config|
config.features = [:core, :activesupport]
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],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+15
View File
@@ -639,6 +639,21 @@ class ContextTest < Minitest::Test
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,
+2 -8
View File
@@ -88,17 +88,11 @@ class HashRenderingTest < Minitest::Test
end
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
my_hash = Class.new(Hash) do
def to_s
"kewl"
end
end.new
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash })
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
end
def test_rendering_hash_without_custom_to_s_uses_default_inspect
my_hash = Class.new(Hash).new
my_hash = HashWithoutCustomToS.new
my_hash[:foo] = :bar
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
+29 -22
View File
@@ -44,33 +44,40 @@ class SecurityTest < Minitest::Test
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
# 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
assert_equal([], (Symbol.all_symbols - current_symbols))
GC.start
end
end
def test_does_not_add_drop_methods_to_symbol_table
assert_no_new_symbols do
assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
end
end
def assert_no_new_symbols
# Run once to trigger any first-time initialization which might create some symbols,
# for example autoload or lazy method parsing might create symbols on first execution.
yield
# Ensure no new symbols for further runs, i.e. the code does not leak symbols
current_symbols = Symbol.all_symbols
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))
yield
assert_equal([], Symbol.all_symbols - current_symbols)
end
def test_max_depth_nested_blocks_does_not_raise_exception
+46 -9
View File
@@ -116,7 +116,7 @@ class StandardFiltersTest < Minitest::Test
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))
@@ -164,6 +164,13 @@ class StandardFiltersTest < Minitest::Test
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('&lt;strong&gt;', @filters.escape('<strong>'))
assert_equal('1', @filters.escape(1))
@@ -294,13 +301,7 @@ class StandardFiltersTest < Minitest::Test
end
def test_join_calls_to_liquid_on_each_element
drop = Class.new(Liquid::Drop) do
def to_liquid
'i did it'
end
end
assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", "))
assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
end
def test_sort
@@ -633,6 +634,40 @@ class StandardFiltersTest < Minitest::Test
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))
@@ -1146,6 +1181,8 @@ class StandardFiltersTest < Minitest::Test
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
@@ -1302,7 +1339,7 @@ class StandardFiltersTest < Minitest::Test
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.4, @filters.sum(input, 1..5))
assert_equal(0, @filters.sum(input, nil))
assert_equal(0, @filters.sum(input, ""))
end
+4 -4
View File
@@ -101,7 +101,7 @@ class CycleTagTest < Minitest::Test
assert_template_result("a", template2)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
@@ -129,7 +129,7 @@ class CycleTagTest < Minitest::Test
assert_template_result("N", template5)
end
with_error_modes(:rigid) do
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) }
@@ -157,7 +157,7 @@ class CycleTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
@@ -174,7 +174,7 @@ class CycleTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
+5 -5
View File
@@ -204,7 +204,7 @@ class IncludeTagTest < Minitest::Test
)
end
def test_rigid_parsing_errors
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
@@ -213,7 +213,7 @@ class IncludeTagTest < Minitest::Test
)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
@@ -408,7 +408,7 @@ class IncludeTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
@@ -421,7 +421,7 @@ class IncludeTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
@@ -434,7 +434,7 @@ class IncludeTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
+8 -11
View File
@@ -101,7 +101,11 @@ class RenderTagTest < Minitest::Test
end
end
def test_rigid_parsing_errors
def test_dynamically_choosen_templates_are_not_allowed
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
end
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
@@ -110,7 +114,7 @@ class RenderTagTest < Minitest::Test
)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
@@ -290,13 +294,6 @@ class RenderTagTest < Minitest::Test
)
end
def test_render_tag_with_snippet_drop
assert_template_result(
"Hello from snippet",
"{% snippet my_snippet %}Hello from snippet{% endsnippet %}{% render my_snippet %}",
)
end
def test_render_tag_renders_error_with_template_name
assert_template_result(
'Liquid error (foo line 1): standard error',
@@ -325,7 +322,7 @@ class RenderTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
@@ -338,7 +335,7 @@ class RenderTagTest < Minitest::Test
refute_nil(Template.parse(template))
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -117,7 +117,7 @@ class StandardTagTest < Minitest::Test
assigns = { 'condition' => "bad string here" }
assert_template_result(
'',
'{% case condition %}{% when "string here" %} hit {% endcase %}',\
'{% case condition %}{% when "string here" %} hit {% endcase %}',
assigns,
)
end
+28 -28
View File
@@ -259,7 +259,7 @@ class TableRowTest < Minitest::Test
)
end
def test_tablerow_with_cols_attribute_in_rigid_mode
def test_tablerow_with_cols_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
LIQUID
@@ -270,12 +270,12 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">4</td><td class="col2">5</td><td class="col3">6</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_limit_attribute_in_rigid_mode
def test_tablerow_with_limit_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
LIQUID
@@ -285,12 +285,12 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_offset_attribute_in_rigid_mode
def test_tablerow_with_offset_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
LIQUID
@@ -300,12 +300,12 @@ class TableRowTest < Minitest::Test
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_range_attribute_in_rigid_mode
def test_tablerow_with_range_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
LIQUID
@@ -315,12 +315,12 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_multiple_attributes_in_rigid_mode
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
@@ -331,12 +331,12 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_variable_collection_in_rigid_mode
def test_tablerow_with_variable_collection_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -347,12 +347,12 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
end
def test_tablerow_with_dotted_access_in_rigid_mode
def test_tablerow_with_dotted_access_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -363,12 +363,12 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
OUTPUT
with_error_modes(:rigid) do
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_rigid_mode
def test_tablerow_with_bracketed_access_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -378,12 +378,12 @@ class TableRowTest < Minitest::Test
<td class="col1">10</td><td class="col2">20</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
end
def test_tablerow_without_attributes_in_rigid_mode
def test_tablerow_without_attributes_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
LIQUID
@@ -393,30 +393,30 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_without_in_keyword_in_rigid_mode
def test_tablerow_without_in_keyword_in_strict2_mode
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
with_error_modes(:rigid) do
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_rigid_mode
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(:rigid) do
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_rigid_mode
def test_tablerow_with_empty_collection_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
LIQUID
@@ -426,12 +426,12 @@ class TableRowTest < Minitest::Test
</tr>
OUTPUT
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'empty_array' => [] })
end
end
def test_tablerow_with_invalid_attribute_strict_vs_rigid
def test_tablerow_with_invalid_attribute_strict_vs_strict2
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
expected = <<~OUTPUT
@@ -443,13 +443,13 @@ class TableRowTest < Minitest::Test
assert_template_result(expected, template)
end
with_error_modes(:rigid) do
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_rigid
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
@@ -460,7 +460,7 @@ class TableRowTest < Minitest::Test
assert_template_result(expected, template)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
+81 -1
View File
@@ -133,7 +133,7 @@ class TemplateTest < Minitest::Test
assert(t.resource_limits.reached?)
t.resource_limits.render_score_limit = 200
assert_equal((" foo " * 100), t.render!)
assert_equal(" foo " * 100, t.render!)
refute_nil(t.resource_limits.render_score)
end
@@ -179,6 +179,86 @@ class TemplateTest < Minitest::Test
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 %}")
+5 -5
View File
@@ -218,7 +218,7 @@ class VariableTest < Minitest::Test
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result('helloworld', template)
end
end
@@ -231,7 +231,7 @@ class VariableTest < Minitest::Test
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result('hello12', template)
end
end
@@ -244,7 +244,7 @@ class VariableTest < Minitest::Test
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result('TEST', template)
end
end
@@ -257,7 +257,7 @@ class VariableTest < Minitest::Test
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
end
@@ -270,7 +270,7 @@ class VariableTest < Minitest::Test
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
end
+20
View File
@@ -199,6 +199,26 @@ class ErrorDrop < Liquid::Drop
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
+173 -7
View File
@@ -161,8 +161,8 @@ class ConditionUnitTest < Minitest::Test
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
@@ -176,19 +176,19 @@ class ConditionUnitTest < Minitest::Test
assert_equal(['title'], result.lookups)
end
def test_parse_expression_in_rigid_mode_raises_internal_error
environment = Environment.build(error_mode: :rigid)
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 rigid mode/, error.message)
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_safe_true_in_rigid_mode
environment = Environment.build(error_mode: :rigid)
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)
@@ -197,6 +197,172 @@ class ConditionUnitTest < Minitest::Test
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)
+256
View File
@@ -0,0 +1,256 @@
# frozen_string_literal: true
require 'test_helper'
# Tests for the byte-walking fast paths introduced in the ByteTables optimization.
# Each fast path is tested for equivalence with the original regex-based code path
# it replaces, covering accepted inputs, rejected inputs, and boundary cases.
class FastPathUnitTest < Minitest::Test
# ── Expression.parse_number ────────────────────────────────────────
# Fast path: byte-walk with ByteTables::DIGIT
# Replaces: INTEGER_REGEX, FLOAT_REGEX, StringScanner loop
def test_parse_number_simple_integers
assert_equal(42, Liquid::Expression.parse_number("42"))
assert_equal(0, Liquid::Expression.parse_number("0"))
assert_equal(999, Liquid::Expression.parse_number("999"))
end
def test_parse_number_negative_integers
assert_equal(-7, Liquid::Expression.parse_number("-7"))
assert_equal(-0, Liquid::Expression.parse_number("-0"))
assert_equal(-123, Liquid::Expression.parse_number("-123"))
end
def test_parse_number_simple_floats
assert_equal(3.14, Liquid::Expression.parse_number("3.14"))
assert_equal(0.5, Liquid::Expression.parse_number("0.5"))
assert_equal(-0.5, Liquid::Expression.parse_number("-0.5"))
assert_equal(100.0, Liquid::Expression.parse_number("100.0"))
end
def test_parse_number_trailing_dot
# "123." → 123.0 (truncate before dot)
assert_equal(123.0, Liquid::Expression.parse_number("123."))
assert_equal(0.0, Liquid::Expression.parse_number("0."))
end
def test_parse_number_multi_dot_floats
# "1.2.3" → 1.2 (truncate at second dot)
assert_equal(1.2, Liquid::Expression.parse_number("1.2.3"))
assert_equal(1.2, Liquid::Expression.parse_number("1.2.3.4"))
assert_equal(0.0, Liquid::Expression.parse_number("0.0.0"))
end
def test_parse_number_rejects_non_numeric
assert_nil(Liquid::Expression.parse_number("hello"))
assert_nil(Liquid::Expression.parse_number(""))
assert_nil(Liquid::Expression.parse_number("abc123"))
assert_nil(Liquid::Expression.parse_number(".5"))
assert_nil(Liquid::Expression.parse_number("-.5"))
end
def test_parse_number_rejects_trailing_alpha_after_multi_dot
# "1.2.3a" must be nil, not 1.2 — these are not valid numbers
assert_nil(Liquid::Expression.parse_number("1.2.3a"))
assert_nil(Liquid::Expression.parse_number("1.2.3.4a"))
assert_nil(Liquid::Expression.parse_number("1.2.34a"))
assert_nil(Liquid::Expression.parse_number("-1.2.3a"))
end
def test_parse_number_rejects_bare_dash
assert_nil(Liquid::Expression.parse_number("-"))
assert_nil(Liquid::Expression.parse_number("-a"))
end
# ── Expression.parse strip guard ───────────────────────────────────
# Fast path: skip String#strip when no leading/trailing whitespace
# Must produce identical results to unconditional .strip
def test_parse_strips_leading_whitespace
assert_equal(42, Liquid::Expression.parse(" 42"))
assert_equal(42, Liquid::Expression.parse("\t42"))
assert_equal(42, Liquid::Expression.parse("\n42"))
end
def test_parse_strips_trailing_whitespace
assert_equal(42, Liquid::Expression.parse("42 "))
assert_equal(42, Liquid::Expression.parse("42\t"))
assert_equal(42, Liquid::Expression.parse("42\n"))
end
def test_parse_strips_both_sides
assert_equal(42, Liquid::Expression.parse(" 42 "))
assert_equal("hello", Liquid::Expression.parse(" 'hello' "))
end
def test_parse_no_strip_needed
assert_equal(42, Liquid::Expression.parse("42"))
assert_equal("hello", Liquid::Expression.parse("'hello'"))
assert_equal(true, Liquid::Expression.parse("true"))
end
def test_parse_strips_null_bytes
# String#strip removes \x00 — the WHITESPACE table must match
assert_equal(true, Liquid::Expression.parse("\x00true"))
assert_equal(true, Liquid::Expression.parse("true\x00"))
end
# ── VariableLookup.simple_lookup? ──────────────────────────────────
# Fast path: regex gate for simple a.b.c chains
# Must accept only inputs the byte-walk can handle correctly
def test_simple_lookup_accepts_single_names
assert(Liquid::VariableLookup.simple_lookup?("product"))
assert(Liquid::VariableLookup.simple_lookup?("x"))
assert(Liquid::VariableLookup.simple_lookup?("_private"))
end
def test_simple_lookup_accepts_dotted_chains
assert(Liquid::VariableLookup.simple_lookup?("product.title"))
assert(Liquid::VariableLookup.simple_lookup?("a.b.c.d"))
end
def test_simple_lookup_accepts_question_marks
assert(Liquid::VariableLookup.simple_lookup?("product.available?"))
assert(Liquid::VariableLookup.simple_lookup?("empty?"))
end
def test_simple_lookup_accepts_hyphens
assert(Liquid::VariableLookup.simple_lookup?("my-var"))
assert(Liquid::VariableLookup.simple_lookup?("my-var.some-field"))
end
def test_simple_lookup_rejects_brackets
refute(Liquid::VariableLookup.simple_lookup?("product[0]"))
refute(Liquid::VariableLookup.simple_lookup?("hash['key']"))
refute(Liquid::VariableLookup.simple_lookup?("[0]"))
end
def test_simple_lookup_rejects_empty_and_malformed
refute(Liquid::VariableLookup.simple_lookup?(""))
refute(Liquid::VariableLookup.simple_lookup?(".leading"))
refute(Liquid::VariableLookup.simple_lookup?("trailing."))
refute(Liquid::VariableLookup.simple_lookup?("a..b"))
end
# ── VariableLookup fast path equivalence ───────────────────────────
# The fast path must produce identical name, lookups, and command_flags
# to the original VariableParser regex path
def test_fast_path_simple_name
vl = Liquid::VariableLookup.new("product")
assert_equal("product", vl.name)
assert_equal([], vl.lookups)
end
def test_fast_path_dotted_chain
vl = Liquid::VariableLookup.new("product.title")
assert_equal("product", vl.name)
assert_equal(["title"], vl.lookups)
end
def test_fast_path_deep_chain
vl = Liquid::VariableLookup.new("a.b.c.d")
assert_equal("a", vl.name)
assert_equal(["b", "c", "d"], vl.lookups)
end
def test_fast_path_command_methods
vl = Liquid::VariableLookup.new("items.size")
assert_equal("items", vl.name)
assert_equal(["size"], vl.lookups)
assert(vl.lookup_command?(0))
vl2 = Liquid::VariableLookup.new("items.first")
assert(vl2.lookup_command?(0))
vl3 = Liquid::VariableLookup.new("items.last")
assert(vl3.lookup_command?(0))
end
def test_fast_path_non_command_lookups
vl = Liquid::VariableLookup.new("product.title")
refute(vl.lookup_command?(0))
end
def test_fast_path_question_mark
vl = Liquid::VariableLookup.new("product.available?")
assert_equal("product", vl.name)
assert_equal(["available?"], vl.lookups)
end
def test_bracket_lookup_falls_to_regex_path
vl = Liquid::VariableLookup.new("product[0]")
assert_equal("product", vl.name)
assert_equal([0], vl.lookups)
end
# ── BlockBody.try_parse_tag_token ──────────────────────────────────
# Fast path: byte-walk tag tokens instead of FullToken regex
# Must produce identical [tag_name, markup, newline_count] or nil
def test_tag_token_simple
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% if x %}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_whitespace_control_leading
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{%- if x %}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_whitespace_control_trailing
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% if x -%}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_whitespace_control_both
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{%- if x -%}")
assert_equal(["if", "x ", 0], result)
end
def test_tag_token_no_markup
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% endif %}")
assert_equal(["endif", "", 0], result)
end
def test_tag_token_hash_comment
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% # this is a comment %}")
assert_equal(["#", "this is a comment ", 0], result)
end
def test_tag_token_with_newlines
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% \n if \n x %}")
assert_equal(["if", "x ", 2], result)
end
def test_tag_token_hyphenated_name_stops_at_hyphen
# TagName = /\w+/ does not include hyphens
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% my-tag markup %}")
assert_equal("my", result[0])
end
def test_tag_token_complex_markup
body = Liquid::BlockBody.new
result = body.send(:try_parse_tag_token, "{% for item in collection reversed %}")
assert_equal("for", result[0])
assert_equal("item in collection reversed ", result[1])
end
def test_tag_token_malformed_returns_nil
body = Liquid::BlockBody.new
# Token too short
assert_nil(body.send(:try_parse_tag_token, "{%"))
# No valid tag name start (digit)
assert_nil(body.send(:try_parse_tag_token, "{% 123 %}"))
end
end
+27 -27
View File
@@ -9,32 +9,32 @@ class ParseContextUnitTest < Minitest::Test
parser_strict = strict_parse_context.new_parser('product.title')
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
parser_rigid = rigid_parse_context.new_parser('product.title')
result_rigid = rigid_parse_context.safe_parse_expression(parser_rigid)
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_rigid)
assert_equal('product', result_rigid.name)
assert_equal(['title'], result_rigid.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_rigid = rigid_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_rigid = assert_raises(Liquid::SyntaxError) do
rigid_parse_context.safe_parse_expression(parser_rigid)
error_strict2 = assert_raises(Liquid::SyntaxError) do
strict2_parse_context.safe_parse_expression(parser_strict2)
end
assert_match(/is not a valid expression/, error_rigid.message)
assert_match(/is not a valid expression/, error_strict2.message)
end
def test_parse_expression_with_variable_lookup
@@ -45,10 +45,10 @@ class ParseContextUnitTest < Minitest::Test
assert_equal(['title'], result_strict.lookups)
error = assert_raises(Liquid::InternalError) do
rigid_parse_context.parse_expression('product.title')
strict2_parse_context.parse_expression('product.title')
end
assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message)
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_safe_true
@@ -58,11 +58,11 @@ class ParseContextUnitTest < Minitest::Test
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
result_rigid = rigid_parse_context.parse_expression('product.title', safe: true)
result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result_rigid)
assert_equal('product', result_rigid.name)
assert_equal(['title'], result_rigid.lookups)
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
@@ -70,40 +70,40 @@ class ParseContextUnitTest < Minitest::Test
assert_nil(result_strict)
error = assert_raises(Liquid::InternalError) do
rigid_parse_context.parse_expression('')
strict2_parse_context.parse_expression('')
end
assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message)
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_rigid = rigid_parse_context.parse_expression('', safe: true)
assert_nil(result_rigid)
result_strict2 = strict2_parse_context.parse_expression('', safe: true)
assert_nil(result_strict2)
end
def test_safe_parse_expression_advances_parser_pointer
parser = rigid_parse_context.new_parser('foo, bar')
parser = strict2_parse_context.new_parser('foo, bar')
# safe_parse_expression consumes "foo"
first_result = rigid_parse_context.safe_parse_expression(parser)
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 = rigid_parse_context.safe_parse_expression(parser)
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_rigid_mode
result = rigid_parse_context.parse_expression(' ', safe: true)
def test_parse_expression_with_whitespace_in_strict2_mode
result = strict2_parse_context.parse_expression(' ', safe: true)
assert_nil(result)
end
@@ -115,9 +115,9 @@ class ParseContextUnitTest < Minitest::Test
)
end
def rigid_parse_context
@rigid_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :rigid),
def strict2_parse_context
@strict2_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict2),
)
end
end
+2 -2
View File
@@ -184,7 +184,7 @@ class PartialCacheUnitTest < Minitest::Test
},
)
[:lax, :warn, :strict, :rigid].each do |error_mode|
[:lax, :warn, :strict, :strict2].each do |error_mode|
Liquid::PartialCache.load(
'my_partial',
context: context,
@@ -193,7 +193,7 @@ class PartialCacheUnitTest < Minitest::Test
end
assert_equal(
["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:rigid"],
["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"],
context.registers[:cached_partials].keys,
)
end
+91
View File
@@ -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
+1 -1
View File
@@ -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)
+26 -6
View File
@@ -24,7 +24,7 @@ class CaseTagUnitTest < Minitest::Test
assert_template_result("one", template)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
@@ -45,7 +45,7 @@ class CaseTagUnitTest < Minitest::Test
assert_template_result("one", template)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
@@ -62,7 +62,7 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :rigid) do
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
end
@@ -77,11 +77,31 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :rigid) do
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 -%}
@@ -97,7 +117,7 @@ class CaseTagUnitTest < Minitest::Test
assert_template_result("one", template, assigns)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
@@ -119,7 +139,7 @@ class CaseTagUnitTest < Minitest::Test
assert_template_result("one", template, assigns)
end
with_error_modes(:rigid) do
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
+2 -2
View File
@@ -161,8 +161,8 @@ class VariableUnitTest < Minitest::Test
end
end
def test_rigid_filter_argument_parsing
with_error_modes(:rigid) do
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)