Compare commits

...
Author SHA1 Message Date
Tobi Lütke 807d45a6b3 Surface blank-body inline errors in strict2
Keep the historical blank-body inline-error suppression for lax and strict parse modes, but stop suppressing the rendered error text when the template was parsed in strict2. Raised-error rendering continues to raise in all modes.

Store the resolved template error mode on the render context while a template renders so BlockBody can distinguish strict2 from compatibility modes when deciding whether a blank tag should hide inline error text.

Add integration coverage for lax/strict suppression, strict2 non-suppression across blank body forms, nonblank bodies, and raised-error behavior.
2026-07-05 18:56:24 +00:00
Guilherme CarreiroandGitHub 7a5e45fc47 Bump to 5.13.0 (#2103) 2026-06-29 17:41:55 +02:00
Ian Ker-SeymerandIan Ker-Seymer 9efca9f718 Use liquid-spec branch for self specs 2026-06-11 23:20:43 -04:00
Ian Ker-SeymerandIan Ker-Seymer 529800f46b Let environment self shadow SelfDrop 2026-06-11 23:20:43 -04:00
Guilherme CarreiroandGitHub 7b368dffb8 Fix SelfDrop equality (#2091) 2026-06-05 10:47:37 +02:00
Guilherme CarreiroandGitHub 742ac3dbf5 Prevent SelfDrop context mutation across render boundaries (#2082) 2026-05-20 09:34:37 +02:00
Ian Ker-SeymerandIan Ker-Seymer 1954a2655c Update liquid-spec adapters 2026-04-28 10:11:38 -04:00
Alok SwamyandGitHub 6d81b1b68c Merge pull request #2077 from Shopify/remove-strict2-from-error-message
Remove "strict2" from bare bracket error message
2026-04-24 16:21:51 -04:00
Alok SwamyandClaude Opus 4.7 dfddd8f390 Remove "strict2" from bare bracket error message
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-24 16:18:05 -04:00
Alok SwamyandGitHub 95ce7e7fa1 Merge pull request #2067 from Shopify/strict2-increment-decrement
Add strict2_parse to increment and decrement tags
2026-04-24 16:11:49 -04:00
Alok SwamyandGitHub 197d755e0c Merge pull request #2065 from Shopify/strict2-assign-capture
Add strict2_parse to assign and capture tags
2026-04-24 16:10:59 -04:00
Alok SwamyandGitHub d0c5444db1 Merge pull request #2060 from Shopify/bare-bracket-self-keyword
Reject bare-bracket syntax in strict2 and introduce `self` keyword
2026-04-24 16:06:02 -04:00
Alok SwamyandClaude Opus 4.6 c99036046e Add strict2_parse to increment and decrement tags
Both tags previously accepted any string as a variable name via
`markup.strip`. Now they use `parse_with_selected_parser` and validate
the variable name with `p.consume(:id)` in strict2 mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 15:32:53 -04: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
Alok SwamyandClaude Opus 4.6 0d5c15a03e Add strict2_parse to assign and capture tags
Both tags previously used only regex (VariableSignature) to validate
variable names, which allowed invalid identifiers like (a(b(c) and
[x.y] in all parse modes.

- assign: strict2_parse uses Parser to validate the LHS as a valid
  identifier before delegating RHS to Variable
- capture: strict2_parse uses Parser to validate the variable name
  as a valid identifier
- Both tags now include ParserSwitching and dispatch through
  strict_parse_with_error_mode_fallback
- Lax mode is unchanged — invalid names are still accepted

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-25 12:07:23 -04: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 SwamyandClaude Opus 4.6 346166b600 Add for_loop? to Include tag for AST-based keyword detection
Store @is_for_loop during parsing so consumers can determine the
with/for keyword from the AST instead of re-parsing raw markup.
Matches the existing pattern in the Render tag.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-24 14:52:55 -04:00
Alok SwamyandClaude Opus 4.6 532b439063 Reject bare-bracket syntax in strict2 and introduce self keyword
Add bare-bracket rejection to Parser#expression in strict2 mode, so that
`['var']` is disallowed and `self['var']` is the required syntax.

- Add `Expression::SELF` constant ('self')
- Add `Parser#reject_bare_brackets` option, checked in `expression`
- Add `ParseContext#reject_bare_brackets?` and `force_reject_bare_brackets`
- Add `VariableLookupDrop` for `self['var']` scope-chain lookups
- Add `Variable#==` for rewriter state comparison
- Update `Context#find_variable` to return `VariableLookupDrop` for `self`

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 12:13:59 -04: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
51 changed files with 1408 additions and 103 deletions
+35 -14
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.3, allowed-failure: false } # minimum supported
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false } # latest stable
- {
ruby: 3.4,
ruby: 4.0,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: head, allowed-failure: false }
- { 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: false,
allowed-failure: true,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: head, allowed-failure: false, rubyopt: "--yjit" }
name: Test Ruby ${{ matrix.entry.ruby }}
- { 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@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.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@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.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'
+1 -1
View File
@@ -1 +1 @@
3.4.1
4.0.2
+9 -2
View File
@@ -25,7 +25,14 @@ 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
# Using feature branch until https://github.com/Shopify/liquid-spec/pull/144 is merged
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'self-drop-env-lookup-specs'
gem 'activesupport', require: false
end
+12
View File
@@ -1,5 +1,17 @@
# Liquid Change Log
## 5.13.0
* Add TruffleRuby in CI [Benoit Daloze]
* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze]
* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy]
* Add strict2_parse to assign and capture tags by [Alok Swamy]
* Add strict2_parse to increment and decrement tags by [Alok Swamy]
* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer]
* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro]
* Fix `SelfDrop` equality [Guilherme Carreiro]
* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer]
## 5.11.0
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
+8
View File
@@ -148,3 +148,11 @@ end
task :console do
exec 'irb -I lib -r liquid'
end
desc('run liquid-spec suite across all adapters')
task :spec do
Dir['./spec/*.rb'].sort.each do |adapter|
puts "=== Running #{adapter} ==="
sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures'
end
end
+1
View File
@@ -65,6 +65,7 @@ require 'liquid/lexer'
require 'liquid/parser'
require 'liquid/i18n'
require 'liquid/drop'
require 'liquid/self_drop'
require 'liquid/tablerowloop_drop'
require 'liquid/forloop_drop'
require 'liquid/extensions'
+3 -1
View File
@@ -99,7 +99,9 @@ module Liquid
context.handle_error(exc, line_number)
else
error_message = context.handle_error(exc, line_number)
unless blank_tag # conditional for backwards compatibility
error_mode = context.registers.static[:template_error_mode]
suppress_error_text = blank_tag && error_mode != :strict2 && error_mode != :rigid
unless suppress_error_text # blank-tag suppression is kept for backwards compatibility outside strict2
output << error_message
end
end
+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
+19 -1
View File
@@ -187,6 +187,15 @@ module Liquid
find_variable(key, raise_on_not_found: false) != nil
end
# Checks whether a variable is defined in any scope, including nil-valued keys.
# Unlike #key?, this uses Hash#key? so that variables explicitly set to nil
# are still considered defined.
def variable_defined?(key)
@scopes.any? { |s| s.key?(key) } ||
@environments.any? { |e| e.key?(key) } ||
@static_environments.any? { |e| e.key?(key) }
end
def evaluate(object)
object.respond_to?(:evaluate) ? object.evaluate(self) : object
end
@@ -197,12 +206,21 @@ module Liquid
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
fallback_to_self_drop = key == Expression::SELF && index.nil?
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
try_variable_find_in_environments(
key,
raise_on_not_found: raise_on_not_found && !fallback_to_self_drop,
)
end
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
# but only after the normal environment lookup doesn't find a value.
return @self_drop ||= SelfDrop.new(self) if fallback_to_self_drop && variable.nil?
# update variable's context before invoking #to_liquid
variable.context = self if variable.respond_to?(:context=)
+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
+3 -1
View File
@@ -2,6 +2,8 @@
module Liquid
class Expression
SELF = 'self'
LITERALS = {
nil => nil,
'nil' => nil,
@@ -55,7 +57,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),
+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
+1 -1
View File
@@ -38,7 +38,7 @@ module Liquid
def new_parser(input)
@string_scanner.string = input
Parser.new(@string_scanner)
Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid)
end
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
+5 -1
View File
@@ -2,10 +2,11 @@
module Liquid
class Parser
def initialize(input)
def initialize(input, reject_bare_brackets: false)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location
@reject_bare_brackets = reject_bare_brackets
end
def jump(point)
@@ -53,6 +54,9 @@ module Liquid
str = consume
str << variable_lookups
when :open_square
if @reject_bare_brackets
raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead"
end
str = consume.dup
str << expression
str << consume(:close_square)
+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
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type object
# @liquid_name self
# @liquid_summary
# Provides access to variables through the current scope chain.
# @liquid_description
# The `self` object resolves variables through the normal lookup hierarchy
# (local > file > global) without exposing filters, interrupts, errors,
# or other context internals. It's used when bare bracket notation
# (`['variable']`) needs to be replaced with an explicit variable lookup.
#
# If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`),
# then the local value takes precedence over the `self` object.
# @liquid_access global
class SelfDrop < Drop
def initialize(self_context)
super()
@self_context = self_context
end
def [](key)
@self_context.find_variable(key)
rescue UndefinedVariable
nil
end
def key?(key)
@self_context.variable_defined?(key)
end
def to_liquid
self
end
def ==(other)
other.is_a?(SelfDrop) && other.self_context.equal?(@self_context)
end
alias_method :eql?, :==
def hash
@self_context.object_id.hash
end
protected
attr_reader :self_context
undef context=
end
end
+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
+25
View File
@@ -18,6 +18,8 @@ module Liquid
# @liquid_syntax_keyword variable_name The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Assign < Tag
include ParserSwitching
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
# @api private
@@ -29,6 +31,10 @@ module Liquid
def initialize(tag_name, markup, parse_context)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
@from = Variable.new(Regexp.last_match(2), parse_context)
@@ -37,6 +43,25 @@ module Liquid
end
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
unless markup =~ Syntax
self.class.raise_syntax_error(parse_context)
end
lhs = Regexp.last_match(1).strip
rhs = Regexp.last_match(2)
p = @parse_context.new_parser(lhs)
@to = p.consume(:id)
p.consume(:end_of_string)
@from = Variable.new(rhs, parse_context)
end
def render_to_output_buffer(context, output)
val = @from.render(context)
context.scopes.last[@to] = val
+18
View File
@@ -20,10 +20,18 @@ module Liquid
# @liquid_syntax_keyword variable The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Capture < Block
include ParserSwitching
Syntax = /(#{VariableSignature}+)/o
attr_reader :to
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
else
@@ -31,6 +39,16 @@ module Liquid
end
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@to = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
context.resource_limits.with_capture do
capture_output = render(context)
+16
View File
@@ -23,13 +23,29 @@ module Liquid
# {% decrement variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
class Decrement < Tag
include ParserSwitching
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
@variable_name = markup.strip
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@variable_name = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
+13 -4
View File
@@ -20,7 +20,8 @@ module Liquid
class Include < Tag
prepend Tag::Disableable
SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
FOR = 'for'
SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
Syntax = SYNTAX
attr_reader :template_name_expr, :variable_name_expr, :attributes
@@ -84,12 +85,18 @@ module Liquid
alias_method :parse_context, :options
private :parse_context
def for_loop?
@is_for_loop
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = safe_parse_expression(p)
@variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with")
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = safe_parse_expression(p) if with_or_for
@alias_name = p.consume(:id) if p.id?("as")
@is_for_loop = (with_or_for == FOR)
p.consume?(:comma)
@@ -111,11 +118,13 @@ module Liquid
def lax_parse(markup)
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
variable_name = Regexp.last_match(3)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(5)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
+16
View File
@@ -23,13 +23,29 @@ module Liquid
# {% increment variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
class Increment < Tag
include ParserSwitching
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
@variable_name = markup.strip
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@variable_name = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
+13 -2
View File
@@ -151,8 +151,10 @@ module Liquid
c
when Liquid::Drop
drop = args.shift
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop = args.shift
c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop.context = c if drop.respond_to?(:context=)
c
when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when nil
@@ -187,12 +189,20 @@ module Liquid
context.template_name ||= name
previous_error_mode = context.registers.static[:template_error_mode]
context.registers.static[:template_error_mode] = @error_mode
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
rescue Liquid::MemoryError => e
context.handle_error(e)
ensure
if previous_error_mode
context.registers.static[:template_error_mode] = previous_error_mode
else
context.registers.static.delete(:template_error_mode)
end
@errors = context.errors
end
end
@@ -224,6 +234,7 @@ module Liquid
end
@warnings = parse_context.warnings
@error_mode = parse_context.error_mode
parse_context
end
+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
+1 -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
+4
View File
@@ -37,6 +37,10 @@ module Liquid
@markup
end
def ==(other)
self.class == other.class && name == other.name && filters == other.filters
end
def markup_context(markup)
"in \"{{#{markup}}}\""
end
+5
View File
@@ -70,6 +70,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.11.0"
VERSION = "5.13.0"
end
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation)
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:activesupport,
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
# @param ctx [Hash] adapter context containing :template
# @param assigns [Hash] environment variables
# @param options [Hash] :registers, :strict_errors, :exception_renderer
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+46
View File
@@ -0,0 +1,46 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with lax parsing mode
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:activesupport,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Default to lax mode while still honoring specs that explicitly set error_mode.
options = { error_mode: :lax }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
# @param ctx [Hash] adapter context containing :template
# @param assigns [Hash] environment variables
# @param options [Hash] :registers, :strict_errors, :exception_renderer
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+53
View File
@@ -0,0 +1,53 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
# Enable YJIT if available
if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
RubyVM::YJIT.enable
end
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force strict mode
options = { error_mode: :strict }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
module LiquidSpecAdapterHelper
extend self
def resource_limits(render_options)
return unless render_options[:resource_limits]
Liquid::ResourceLimits.new({}).tap do |limits|
render_options[:resource_limits].each do |key, value|
limits.public_send(:"#{key}=", value)
end
end
end
def with_frozen_time(&block)
original_tz = ENV['TZ']
ENV['TZ'] = 'UTC'
Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block)
ensure
ENV['TZ'] = original_tz
end
end
+40
View File
@@ -97,6 +97,46 @@ class AssignTest < Minitest::Test
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
end
def test_assign_with_valid_identifier_in_strict2
assert_template_result("hello", "{% assign my_var = 'hello' %}{{ my_var }}", error_mode: :strict2)
end
def test_assign_with_hyphen_in_strict2
assert_template_result("hello", "{% assign my-var = 'hello' %}{{ my-var }}", error_mode: :strict2)
end
def test_assign_rejects_parentheses_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign (a(b(c) = 1234 %}", error_mode: :strict2)
end
end
def test_assign_rejects_brackets_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign [x.y] = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_rejects_dot_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign a.b = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_rejects_numeric_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign 1abc = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_allows_invalid_names_in_lax
assert_template_result("1234", "{% assign (a(b(c) = 1234 %}{{ self['(a(b(c)'] }}", error_mode: :lax)
end
def test_assign_with_filter_in_strict2
assert_template_result("HELLO", "{% assign my_var = 'hello' | upcase %}{{ my_var }}", error_mode: :strict2)
end
private
class ObjectWrapperDrop < Liquid::Drop
@@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'test_helper'
class BlankBodyErrorHandlingTest < Minitest::Test
COMPARISON_ERROR = 'Liquid error (line 1): comparison of Integer with String failed'
INVALID_INTEGER_ERROR = 'Liquid error (line 1): invalid integer'
def render_inline(source, error_mode:, assigns: {})
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render(assigns, render_errors: true)
end
def assert_render_raises(source, error_mode:, assigns: {}, message: nil)
error = assert_raises(Liquid::ArgumentError) do
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns)
end
assert_includes error.message, message if message
end
def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal '', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode)
end
end
def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal '', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode)
end
end
def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal '', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' })
end
end
def test_strict2_blank_if_body_shows_inline_error_text
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2)
end
def test_strict2_whitespace_if_body_shows_inline_error_text
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2)
end
def test_strict2_assign_if_body_shows_inline_error_text
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2)
end
def test_strict2_comment_if_body_shows_inline_error_text
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2)
end
def test_strict2_capture_if_body_shows_inline_error_text
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2)
end
def test_strict2_blank_unless_body_shows_inline_error_text
assert_equal COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2)
end
def test_strict2_blank_for_body_shows_inline_error_text
assert_equal INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' })
end
def test_nonblank_bodies_show_inline_error_text_in_all_modes
[:lax, :strict, :strict2].each do |mode|
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode)
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode)
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode)
end
end
def test_raised_errors_are_not_swallowed_by_blank_if_body
[:lax, :strict, :strict2].each do |mode|
assert_render_raises('{% if 5 > "x" %}{% endif %}', error_mode: mode, message: 'comparison of Integer with String failed')
end
end
def test_raised_errors_are_not_swallowed_by_blank_for_body
[:lax, :strict, :strict2].each do |mode|
assert_render_raises('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }, message: 'invalid integer')
end
end
end
+36 -1
View File
@@ -6,7 +6,11 @@ class CaptureTest < Minitest::Test
include Liquid
def test_captures_block_content_in_variable
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {})
assert_template_result("test string", "{% capture var %}test string{% endcapture %}{{var}}", {})
end
def test_captures_block_content_in_quoted_variable_in_lax
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}, error_mode: :lax)
end
def test_capture_with_hyphen_in_variable_name
@@ -49,4 +53,35 @@ class CaptureTest < Minitest::Test
t.render!
assert_equal(9, t.resource_limits.assign_score)
end
def test_capture_with_valid_identifier_in_strict2
assert_template_result("hello", "{% capture my_var %}hello{% endcapture %}{{ my_var }}", error_mode: :strict2)
end
def test_capture_with_hyphen_in_strict2
assert_template_result("hello", "{% capture my-var %}hello{% endcapture %}{{ my-var }}", error_mode: :strict2)
end
def test_capture_rejects_parentheses_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_rejects_dot_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture a.b %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_rejects_numeric_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture 1abc %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_allows_invalid_names_in_lax
t = Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :lax)
assert_equal("(x[y", t.root.nodelist.first.to)
end
end
+2 -2
View File
@@ -296,8 +296,8 @@ class ContextTest < Minitest::Test
end
def test_access_variable_with_hash_notation
assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
assert_template_result('baz', '{{ foo }}', { "foo" => "baz" })
assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
end
def test_access_hashes_with_hash_access_variables
+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
+120
View File
@@ -0,0 +1,120 @@
# frozen_string_literal: true
require 'test_helper'
class SelfDropContextTest < Minitest::Test
include Liquid
def test_self_drop_passed_as_render_param_preserves_original_scope
source = <<~LIQUID
{%- assign var = 42 -%}
{%- assign s = self -%}
{%- render "snippet1", other_self: s -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign var = 43 -%}
{{- other_self.var }}|{{ self.var -}}
LIQUID
}
assert_template_result('42|43', source, partials: partials)
end
def test_self_drop_in_render_without_passing_resolves_inner_scope
source = <<~LIQUID
{%- assign var = 42 -%}
{%- render "snippet1" -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign var = 99 -%}
{{- self.var -}}
LIQUID
}
assert_template_result('99', source, partials: partials)
end
def test_self_drop_passed_to_nested_renders_preserves_each_level
source = <<~LIQUID
{%- assign a = 1 -%}
{%- assign s1 = self -%}
{%- render "snippet1", outer: s1 -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign a = 2 -%}
{%- assign s2 = self -%}
{%- render "snippet2", outer: outer, middle: s2 -%}
LIQUID
'snippet2' => <<~LIQUID,
{%- assign a = 3 -%}
{{- outer.a }}|{{ middle.a }}|{{ self.a -}}
LIQUID
}
assert_template_result('1|2|3', source, partials: partials)
end
def test_self_drop_reflects_variables_assigned_after_creation
source = <<~LIQUID
{%- assign s = self -%}
{%- assign x = 42 %}{{ s.x -}}
LIQUID
assert_template_result('42', source)
end
def test_self_drop_context_setter_is_undefined
context = Context.new
drop = SelfDrop.new(context)
refute(drop.respond_to?(:context=))
assert_template_result('42', '{{ self.x }}', { 'x' => 42 })
end
def test_self_drop_repeated_lookups_compare_equal_for_same_context
context = Context.new
drop = context.find_variable("self")
cached_drop = context.find_variable("self")
assert_same(drop, cached_drop)
assert_equal(drop.object_id, cached_drop.object_id)
assert_equal(drop, cached_drop)
end
def test_assigned_self_drop_compares_equal_to_itself
assert_template_result('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}')
end
def test_distinct_self_assignments_compare_equal_for_same_context
assert_template_result('T', '{% assign a = self %}{% assign b = self %}{% if a == b %}T{% else %}F{% endif %}')
end
def test_bare_self_compares_equal_to_bare_self
assert_template_result('T', '{% if self == self %}T{% else %}F{% endif %}')
end
def test_self_drop_with_strict_variables_does_not_raise_for_defined_var
t = Template.parse('{{ self.x }}')
result = t.render({ 'x' => 42 }, strict_variables: true)
assert_equal('42', result)
end
def test_self_drop_with_strict_variables_returns_nil_for_undefined_var
t = Template.parse('{{ self.x }}')
result = t.render({}, strict_variables: true)
assert_equal('', result)
end
def test_self_drop_can_be_passed_as_bare_drop_to_render
t = Template.parse('{{ self.x }}')
drop = SelfDrop.new(Context.new({ 'x' => 42 }))
result = t.render(drop)
assert_equal('42', result)
end
end
+45 -2
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))
@@ -627,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))
@@ -1140,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
@@ -1296,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
+2 -4
View File
@@ -105,10 +105,8 @@ class CycleTagTest < Minitest::Test
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
assert_match(/Liquid syntax error:/, error1.message)
assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message)
end
end
+45
View File
@@ -439,4 +439,49 @@ class IncludeTagTest < Minitest::Test
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_for_loop_true_with_for_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' for products %}")
include_node = template.root.nodelist.first
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' keyword")
end
end
def test_include_for_loop_false_with_with_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' with product %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' keyword")
end
end
def test_include_for_loop_false_without_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'header' %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false when no keyword")
end
end
def test_include_for_loop_with_alias
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' for products as item %}")
include_node = template.root.nodelist.first
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' with alias")
end
end
def test_include_with_keyword_and_alias
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' with products[0] as item %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' with alias")
end
end
end # IncludeTagTest
@@ -27,4 +27,50 @@ class IncrementTagTest < Minitest::Test
'{%decrement starboard %}',
)
end
def test_increment_strict2_rejects_invalid_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% increment foo bar %}', error_mode: :strict2)
end
end
def test_increment_strict2_rejects_variable_starting_with_number
assert_raises(Liquid::SyntaxError) do
Template.parse('{% increment 11aa %}', error_mode: :strict2)
end
end
def test_increment_strict2_accepts_valid_variable_name
template = Template.parse('{% increment my-var %}', error_mode: :strict2)
assert_equal('0', template.render)
end
def test_decrement_strict2_rejects_invalid_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% decrement foo bar %}', error_mode: :strict2)
end
end
def test_decrement_strict2_rejects_variable_starting_with_number
assert_raises(Liquid::SyntaxError) do
Template.parse('{% decrement 11aa %}', error_mode: :strict2)
end
end
def test_decrement_strict2_accepts_valid_variable_name
template = Template.parse('{% decrement my-var %}', error_mode: :strict2)
assert_equal('-1', template.render)
end
def test_increment_strict2_rejects_empty_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% increment %}', error_mode: :strict2)
end
end
def test_decrement_strict2_rejects_empty_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% decrement %}', error_mode: :strict2)
end
end
end
+4 -4
View File
@@ -174,16 +174,16 @@ class RenderTagTest < Minitest::Test
def test_increment_is_isolated_between_renders
assert_template_result(
'010',
'{% increment %}{% increment %}{% render "incr" %}',
partials: { 'incr' => '{% increment %}' },
'{% increment port %}{% increment port %}{% render "incr" %}',
partials: { 'incr' => '{% increment port %}' },
)
end
def test_decrement_is_isolated_between_renders
assert_template_result(
'-1-2-1',
'{% decrement %}{% decrement %}{% render "decr" %}',
partials: { 'decr' => '{% decrement %}' },
'{% decrement port %}{% decrement port %}{% render "decr" %}',
partials: { 'decr' => '{% decrement port %}' },
)
end
+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
+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
@@ -55,7 +55,7 @@ class VariableTest < Minitest::Test
def test_expression_with_whitespace_in_square_brackets
assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } })
assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
end
def test_ignore_unknown
@@ -135,17 +135,17 @@ class VariableTest < Minitest::Test
end
def test_dynamic_find_var
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_raw_value_variable
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_dynamic_find_var_with_drop
assert_template_result(
'bar',
'{{ [list[settings.zero]] }}',
'{{ self[list[settings.zero]] }}',
{
'list' => ['foo'],
'settings' => SettingsDrop.new("zero" => 0),
@@ -155,7 +155,7 @@ class VariableTest < Minitest::Test
assert_template_result(
'foo',
'{{ [list[settings.zero]["foo"]] }}',
'{{ self[list[settings.zero]["foo"]] }}',
{
'list' => [{ 'foo' => 'bar' }],
'settings' => SettingsDrop.new("zero" => 0),
+168 -2
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
@@ -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)
+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)