Compare commits

...
Author SHA1 Message Date
Tobi Lutke 3befa567b1 Add hermetic template recording and replay 2026-08-07 12:30:55 -04:00
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
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
Guilherme CarreiroandGuilherme Carreiro 248f3a412f Bump to 5.10.0 2025-10-30 12:28:52 +01:00
Guilherme CarreiroandGuilherme Carreiro a16ec56a40 Update error handling for keeping backward-compatibility on error messages in the render tag 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 12fd93fbe2 Missing inline snippets should display same error as filebased 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 5ceb0e9cec Raise error on invalid snippet name 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 98fbd985d8 Remove unneeded read method 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro ae05ba071c Add liquid_public_docs yard tag to snippet tag 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 4205131148 Extract snippet resource scoring logic into assign_score_of 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro db350c54ff Allow render tag to recognize drops that respond to to_partial 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 0cc6cdd553 Remove ... syntax references 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 40e45e32ac Raise syntax error on incorrect render identifier type 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro d4d2237b90 Support prop spreading 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 0ceeefba02 Implement resource limits and remove leftover string references 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 65fb80a347 Render arguments should maintain correct precedence 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 489a03118c Remove inline snippet specific example files 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 99116638fd Support with, for, and as inline snippet syntax
This commit updates the render method to share parts
of the snippet and block rendering logic to enable
inline snippets to support `with`, `for`, and `as`
syntax
2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 9bcfd32e65 Support ... inline snippet syntax 2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro c7ad1c90ca Change inline snippet identifier from string to variable
Currently, snippet files identified by strings. This
PR makes changes to render to allow for new inline
snippets to use variables as identifiers instead
2025-10-30 12:00:44 +01:00
Julia BoutinandGuilherme Carreiro 12bbbc4537 Create SnippetDrop and set in scope 2025-10-30 12:00:44 +01:00
1eca707c4a Update inline snippets syntax
Previously, inline snippets syntax looked a bit
different, they:

- used strings as tag identifiers
- defined tag arguments {% snippet "input" |type| %}

This PR updates snippets to better reflect
the currently proposed syntax

Co-authored-by: Orlando Qiu <[email protected]>
2025-10-30 12:00:44 +01:00
Josh FaiganandGuilherme Carreiro ed9c4e31c4 Introduce new inline snippets tag
Inline snippets will reduce code duplication and
improve the developer experience, eliminating the
need for one-off snippet files
2025-10-30 12:00:44 +01:00
Guilherme CarreiroandGuilherme Carreiro c357f91e0c Bump to 5.9.0 2025-10-27 17:25:36 +01:00
Guilherme CarreiroandGuilherme Carreiro 1f58216f48 Add unit test mixing positional and kwargs arguments 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGray Gilmore d38795168b Update test/integration/tags/table_row_test.rb
Co-authored-by: Gray Gilmore <[email protected]>
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGray Gilmore 51f312b220 Update README.md
Co-authored-by: Gray Gilmore <[email protected]>
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 44f0429c08 Extract /\w+:0x\h{8}/ regex to UNNAMED_CYCLE_PATTERN constant 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro e57b7efe4e Simplify render/include tags following PR review feedback 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 15430c0770 Add rigid mode to rake benchmark task
The benchmark results show that rigid mode performs a bit better than both strict
and lax modes across most metrics, including tokenization, parsing, rendering,
and their combined operations. Rigid mode consistently delivers the highest
number of iterations per second, with performance differences staying within
1–2% compared to the other modes

```
================================================================================
/opt/rubies/3.4.1/bin/ruby ./performance/benchmark.rb lax

Running benchmark for 20 seconds (with 10 seconds warmup).

ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
           tokenize:   332.000 i/100ms
              parse:    14.000 i/100ms
             render:    61.000 i/100ms
     parse & render:    11.000 i/100ms
Calculating -------------------------------------
           tokenize:      3.325k (± 1.0%) i/s  (300.73 μs/i) -    66.732k in  20.070562s
              parse:    148.166 (± 0.7%) i/s    (6.75 ms/i)  -     2.968k in  20.032971s
             render:    654.428 (± 4.0%) i/s    (1.53 ms/i)  -    13.115k in  20.090452s
     parse & render:    116.108 (± 1.7%) i/s    (8.61 ms/i)  -     2.332k in  20.089221s

================================================================================
/opt/rubies/3.4.1/bin/ruby ./performance/benchmark.rb strict

Running benchmark for 20 seconds (with 10 seconds warmup).

ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
           tokenize:   332.000 i/100ms
              parse:    14.000 i/100ms
             render:    61.000 i/100ms
     parse & render:    11.000 i/100ms
Calculating -------------------------------------
           tokenize:      3.332k (± 0.2%) i/s  (300.14 μs/i) -    66.732k in  20.029095s
              parse:    145.674 (± 0.0%) i/s    (6.86 ms/i)  -     2.926k in  20.086104s
             render:    656.711 (± 4.6%) i/s    (1.52 ms/i)  -    13.115k in  20.050810s
     parse & render:    114.705 (± 0.0%) i/s    (8.72 ms/i)  -     2.299k in  20.043028s

================================================================================
/opt/rubies/3.4.1/bin/ruby ./performance/benchmark.rb rigid

Running benchmark for 20 seconds (with 10 seconds warmup).

ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23]
Warming up --------------------------------------
           tokenize:   333.000 i/100ms
              parse:    14.000 i/100ms
             render:    62.000 i/100ms
     parse & render:    11.000 i/100ms
Calculating -------------------------------------
           tokenize:      3.334k (± 0.3%) i/s  (299.93 μs/i) -    66.933k in  20.075484s
              parse:    148.349 (± 2.0%) i/s    (6.74 ms/i)  -     2.968k in  20.019775s
             render:    663.752 (± 2.6%) i/s    (1.51 ms/i)  -    13.268k in  20.010303s
     parse & render:    116.464 (± 2.6%) i/s    (8.59 ms/i)  -     2.332k in  20.037869s
liquid$
```
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 6aa4041c0f Rename with_error_mode(...) to with_error_modes(...) 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandAlok Swamy d63dd104de Update test/integration/tags/render_tag_test.rb
Co-authored-by: Alok Swamy <[email protected]>
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandAlok Swamy 1733586242 Update test/unit/tags/case_tag_unit_test.rb
Co-authored-by: Alok Swamy <[email protected]>
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandAlok Swamy 5a210fc8f6 Update Rakefile
Co-authored-by: Alok Swamy <[email protected]>
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 8d7bb9b6f8 Update History.md (5.8.8 -> 5.9.0) 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 4f35764d44 Update History.md 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 018442b7d1 Covered changes with more tests, remove redundant cases, and the new with_error_mode(*modes)
Most of changes update this:
```
[:lax, :strict].each do |mode|
  with_error_mode(mode) do
    assert_template_result(...
```

to be this:
```
with_error_mode(:lax, :strict) do
  assert_template_result(...
```
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro a23c71e40b Fix variable to keep it backward-compatible in strict mode
* lax_parse    - no changes
  * strict_parse - uses the `lax_parse_filter_expressions` (as it was doing before)
  * rigid_parse  - uses the `rigid_parse_filter_expressions`
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 738540a601 Update infrastructure that handles parsing switching:
* Remove development helpers from parse context
* Simplify strict_parse_with_error_mode_fallback and update
  documentation
* Add unit tests for `Liquid::Expression` and `Liquid::ParseContext`
* Update test helpers to work better with the `:rigid` mode
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 4cd367d971 * Update bin/render script to present an error when no template is passed
* Remove `bin/example.liquid` as it's not executable
2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 34ab3bdc8e Fix assert_template_result tests not picking up Liquid::Environment.default.error_mode
The `rake test` command gave us the impression that we were running all the tests
on all the error modes, that was false.
2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 1be1e36a8d Fixup cycle rigid parsing to be backwards compatible 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 902ff978a6 Fixup include parsing of with expression 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 2ba81b3f1a Fix alias parsing 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 5660ce6945 render end of string is not optional 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 5ec3008b37 Add rigid parser to tablerow tag 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 5248025439 Remove redundant tests where rigid and strict modes have the same
behavior
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 0bc69b86b7 No longer test ParseContext directly on RigidModeUnitTest as
now the `safe: true` calls are considered safe

Test the entire template instead
2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro b5fbad08c6 rigid set_attribute in for parsing 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro b8958f626d Stricter 1:1 refactor of strict_parse for Variable 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 94bbf6ca32 Make it possible to safe_parse subsets of expressions
e.g. sometimes you want to only accept strings | lookups.
{% render snippetName %} for example. snippetName is a string right now.
We don't want safe_parse_expression because this would allow snippetName
to be a number, a boolean, etc. But we still want to strict parse this.

So what we'll do is use parse_expression(string, safe: true), this is
an optional opt-in to say "I know what I'm doing". Usually that's
because you're using the output of Parser#something as the input of
parse_expression.

It is true that Parser#expression is subset of Expression.parse, it is
not true of the opposite (e.g. Expression.parse doesn't care about .5
and happily parses that as a global lookup of the variable named "5",
Parser#expression throws for that.)

diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb
index e5c321dc..9ab350f0 100644
--- a/lib/liquid/condition.rb
+++ b/lib/liquid/condition.rb
@@ -48,8 +48,8 @@ module Liquid
       @@operators
     end

-    def self.parse_expression(parse_context, markup)
-      @@method_literals[markup] || parse_context.parse_expression(markup)
+    def self.parse_expression(parse_context, markup, safe: false)
+      @@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
     end

     attr_reader :attachment, :child_condition
diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb
index 1c59fe4a..82cf5768 100644
--- a/lib/liquid/parse_context.rb
+++ b/lib/liquid/parse_context.rb
@@ -51,13 +51,13 @@ module Liquid
     end

     def safe_parse_expression(parser)
-      Expression.safe_parse(parser)
+      Expression.safe_parse(parser, @string_scanner, @expression_cache)
     end

-    def parse_expression(markup)
+    def parse_expression(markup, safe: false)
       # todo(guilherme): remove this once rigid mode is fully using safe_parse_expression
-      # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if @error_mode == :rigid
-      puts("🚨 parse_expression used in rigid mode") if @error_mode == :rigid
+      # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if !safe && @error_mode == :rigid
+      puts("🚨 parse_expression used in rigid mode") if !safe && @error_mode == :rigid

       Expression.parse(markup, @string_scanner, @expression_cache)
     end
diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb
index 656d2e47..374ee511 100644
--- a/lib/liquid/tag.rb
+++ b/lib/liquid/tag.rb
@@ -72,8 +72,8 @@ module Liquid
       parse_context.safe_parse_expression(parser)
     end

-    def parse_expression(markup)
-      parse_context.parse_expression(markup)
+    def parse_expression(markup, safe: false)
+      parse_context.parse_expression(markup, safe: safe)
     end
   end
 end
diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb
index c2be5db1..3182983b 100644
--- a/lib/liquid/tags/for.rb
+++ b/lib/liquid/tags/for.rb
@@ -93,7 +93,7 @@ module Liquid
       raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')

       collection_name  = p.expression
-      @collection_name = parse_expression(collection_name)
+      @collection_name = parse_expression(collection_name, safe: true)

       @name     = "#{@variable_name}-#{collection_name}"
       @reversed = p.id?('reversed')
diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb
index 342374f1..e25d6250 100644
--- a/lib/liquid/tags/if.rb
+++ b/lib/liquid/tags/if.rb
@@ -81,8 +81,8 @@ module Liquid
       block.attach(new_body)
     end

-    def parse_expression(markup)
-      Condition.parse_expression(parse_context, markup)
+    def parse_expression(markup, safe: false)
+      Condition.parse_expression(parse_context, markup, safe: safe)
     end

     def lax_parse(markup)
@@ -124,9 +124,9 @@ module Liquid
     end

     def parse_comparison(p)
-      a = parse_expression(p.expression)
+      a = parse_expression(p.expression, safe: true)
       if (op = p.consume?(:comparison))
-        b = parse_expression(p.expression)
+        b = parse_expression(p.expression, safe: true)
         Condition.new(a, op, b)
       else
         Condition.new(a)
diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb
index 6cdbfd6f..b72a235b 100644
--- a/lib/liquid/tags/include.rb
+++ b/lib/liquid/tags/include.rb
@@ -87,10 +87,11 @@ module Liquid
     def rigid_parse(markup)
       p = @parse_context.new_parser(markup)

-      template_name = p.expression
+      @template_name_expr = safe_parse_expression(p)
       with_or_for = p.id?("for") || p.id?("with") || nil
+      @variable_name_expr = nil
       if with_or_for
-        variable_name = p.expression
+        @variable_name_expr = parse_expression(p.consume(:id), safe: true)
       end

       alias_name = nil
@@ -98,8 +99,6 @@ module Liquid
         alias_name = p.consume(:id)
       end

-      @template_name_expr = parse_expression(template_name)
-      @variable_name_expr = variable_name ? parse_expression(variable_name) : nil
       @alias_name = alias_name

       # optional comma
@@ -109,7 +108,7 @@ module Liquid
       while p.look(:id)
         key = p.consume
         p.consume(:colon)
-        @attributes[key] = parse_expression(p.expression)
+        @attributes[key] = safe_parse_expression(p)
         p.consume?(:comma) # optional comma
       end
     end
diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb
index 89c11063..4f716b24 100644
--- a/lib/liquid/tags/render.rb
+++ b/lib/liquid/tags/render.rb
@@ -88,10 +88,11 @@ module Liquid
     def rigid_parse(markup)
       p = @parse_context.new_parser(markup)

-      template_name = rigid_template_name(p)
+      @template_name_expr = parse_expression(rigid_template_name(p), safe: true)
+      @variable_name_expr = nil
       with_or_for = p.id?("for") || p.id?("with") || nil
       if with_or_for
-        variable_name = p.expression
+        @variable_name_expr = safe_parse_expression(p)
       end

       alias_name = nil
@@ -99,8 +100,6 @@ module Liquid
         alias_name = p.consume(:id)
       end

-      @template_name_expr = parse_expression(template_name)
-      @variable_name_expr = variable_name ? parse_expression(variable_name) : nil
       @alias_name = alias_name
       @is_for_loop = (with_or_for == FOR)

@@ -111,7 +110,7 @@ module Liquid
       while p.look(:id)
         key = p.consume
         p.consume(:colon)
-        @attributes[key] = parse_expression(p.expression)
+        @attributes[key] = safe_parse_expression(p)
         p.consume?(:comma) # optional comma
       end
     end
diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb
index 20957065..a3623bc5 100644
--- a/lib/liquid/variable.rb
+++ b/lib/liquid/variable.rb
@@ -65,11 +65,11 @@ module Liquid

       return if p.look(:end_of_string)

-      @name = parse_context.parse_expression(p.expression)
+      @name = parse_context.safe_parse_expression(p)
       while p.consume?(:pipe)
         filtername = p.consume(:id)
         filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
-        @filters << parse_filter_expressions(filtername, filterargs)
+        @filters << parse_filter_expressions(filtername, filterargs, safe: true)
       end
       p.consume(:end_of_string)
     end
@@ -122,15 +122,15 @@ module Liquid

     private

-    def parse_filter_expressions(filter_name, unparsed_args)
+    def parse_filter_expressions(filter_name, unparsed_args, safe: false)
       filter_args  = []
       keyword_args = nil
       unparsed_args.each do |a|
-        if (matches = a.match(JustTagAttributes))
+        if (matches = a.match(JustTagAttributes)) # we'll need to fix this
           keyword_args           ||= {}
-          keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
+          keyword_args[matches[1]] = parse_context.parse_expression(matches[2], safe: false)
         else
-          filter_args << parse_context.parse_expression(a)
+          filter_args << parse_context.parse_expression(a, safe: safe)
         end
       end
       result = [filter_name, filter_args]
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 4f7dafbcac Use safe_parse_expression instead of parse_expression 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro d77662cd0e Remove unnecessary skips 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 65a1c167b3 Add rigid_parse to case/when 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro e2a15334f0 Fail with trailing elements in the cycle tag 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 327790cdce Fix an int the cycle tag, add extra unit tests, and updated parser switcher:
- Fixed NoMethod error with .peek (using look instead)
  - Add friendlier error message when {% cycle %}
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 75c95d0791 Fix cycle tag
- `respond_to?` was returning `false` in the parser switcher
     because `rigid_parse` was private
     It was working before because `parse_context` was doing
     the double-parsing thing, but when we removed that, this
     test fairly started breaking
2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro e413104e78 Remove ExpressionParser in favor of ParseContext#safe_parse 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro d56e3c50f9 Use ExpressionParser in the ParseContext when parsing in :rigid mode 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro 1bff382ebc Add ExpressionParser and ExpressionConsumer 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 6a9e46dd19 Add rigid_parse method to include 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro e58ac0e75b Add rigid_parse to render 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro c78bf20010 Add a rigid_parse method to cycle 2025-10-27 16:33:31 +01:00
Charles-P. ClermontandGuilherme Carreiro 6d585a24f1 Add rigid_parse_with_error_context and clarifications 2025-10-27 16:33:31 +01:00
Guilherme CarreiroandGuilherme Carreiro edf06c2882 Introduce :rigid parsing mode 2025-10-27 16:33:31 +01:00
Michael GoandGitHub 1c1e711906 Merge pull request #1995 from Shopify/avoid-regex-allocation
avoid new regex allocation in util functions
2025-10-15 10:43:52 -03:00
Michael Go b5b36665e6 avoid new regex allocation in util functions 2025-10-15 10:08:13 -03:00
Alok SwamyandGitHub 9942592ea8 Merge pull request #1984 from Shopify/update-loop-named-params
Update liquid docs for loop's named params
2025-09-08 06:24:06 -04:00
Alok Swamy 786381c762 Update liquid docs for loop's named params 2025-09-05 16:43:48 -04:00
Gray GilmoreandGitHub 8ad91b5e36 Merge pull request #1952 from Shopify/jus/template.rb-typo-docs
template.rb: Correct typo in docs
2025-09-03 09:03:54 -07:00
iainandGitHub 9bb7fbf123 Merge pull request #1968 from Shopify/shopify-dev-docs-formatting
Inline some information that previously lived at category level
2025-07-03 10:59:58 -04:00
Iain Campbell 8555fd8a20 inline warning previuosly at category level 2025-06-27 16:57:00 -04:00
James MengandGitHub 9bd408f5d0 Merge pull request #1965 from Shopify/jm/bump_liquid
Bump Liquid to 5.8.7
2025-06-09 12:42:27 -07:00
James Meng 79b831d96c Bump Liquid to 5.8.7 2025-06-09 12:38:04 -07:00
James MengandGitHub aebd75e5e8 Merge pull request #1954 from Shopify/jm/doc_body
Expose tag body in the Doc tag
2025-06-09 12:25:19 -07:00
James Meng 7f2f8a226b Add tests for new public methods 2025-06-09 12:24:59 -07:00
James Meng 7b2b25fda1 Fix Doc tag blank? method to check body content
Previously the blank? method always returned true. Now it properly checks
if the body is empty, making the tag behavior consistent with other tags.

Also updated test to use whitespace control for cleaner assertions.
2025-06-09 11:36:18 -07:00
James Meng 8548b96a97 Remove body attr_reader and initiliaze @body instance variable in parse method 2025-06-06 13:08:06 -07:00
upgrade-umpire[bot]andGitHub fc96e66e14 Merge pull request #1953 from Shopify/actions-commit
Applying Merge
2025-06-05 20:15:14 +00:00
James Meng 79a771d724 Add test for doc tag capturing token before enddoc 2025-06-04 19:30:47 -07:00
James Meng 65b1dedac5 Expose tag body in the Doc tag 2025-06-04 12:04:41 -07:00
Ian Ker-SeymerandGitHub f375d7b3aa Add unit test for custom Liquid tag registration (#1960)
Adds EnvironmentTest to verify custom tag registration and rendering.
2025-05-22 11:38:10 -04:00
James MengandGitHub c6f05eaf83 Merge pull request #1955 from Shopify/jm/trim_doc_tag_desc
Remove {{ foo }} and {{ bar }} from `doc` tag description
2025-05-06 15:29:29 -07:00
James Meng eabbd5cb6f Remove {{ foo }} and {{ bar }} from doc tag description 2025-05-06 15:20:35 -07:00
Brian Chen 6b3f6c6fb4 update github actions to commits 2025-04-29 14:04:22 -04:00
Justin D. HarrisandGitHub aefd48e341 template.rb: Correct typo in docs 2025-04-22 09:50:47 -04:00
Marco Concetto RudilossoandGitHub ea864f1177 Merge pull request #1951 from Shopify/actual-revert
Fully revert calling to_s on filter array
2025-04-14 17:49:47 +02:00
Marco Concetto Rudilosso c34dd812c5 Fully revert calling to_s on filter array 2025-04-14 17:36:45 +02:00
Marco Concetto RudilossoandGitHub cc04892e54 Merge pull request #1943 from Shopify/revert-to-s
Revert `Utils.to_s` on all array filters
2025-04-14 17:22:10 +02:00
Marco Concetto Rudilosso f676e699a4 rollback changes to tests 2025-04-14 11:24:01 +02:00
Marco Concetto Rudilosso fc0f7f44c1 use Utils.to_s on property error 2025-04-14 11:17:59 +02:00
Marco Concetto Rudilosso b459eb656f remove test 2025-04-14 11:17:46 +02:00
Marco Concetto Rudilosso c6dcf3e714 add test 2025-04-14 11:17:46 +02:00
Marco Concetto Rudilosso 4dae678c63 Revert "Stringify properties before filtering (#1929)"
This reverts commit f5d6a36574.
2025-04-14 11:17:46 +02:00
Marco Concetto Rudilosso 4c07ff920b Revert "Always stringify properties in all array filters (#1936)"
This reverts commit aa1640035f.
2025-04-14 11:17:46 +02:00
Ian Ker-SeymerandGitHub dbe709c3bf Use to_liquid_value in uniq filter (#1948)
* Use to_liquid_value in uniq filter

* Bump version to 5.8.4
2025-04-09 15:00:01 -04:00
2b75bfaff4 Fix regression when using empty/nil properties with array filters (#1944)
* Make all array filters that use `filter_array` util process empty string and nil correctly

* up version

* fix ordering of checks

* also do it for map

* Do not raise property error

* Gracefully empty property in map filter

---------

Co-authored-by: Marco Concetto Rudilosso <[email protected]>
2025-04-04 11:27:29 -04:00
James MengandGitHub 87bc6e7cfa Merge pull request #1940 from Shopify/jm/update_snippet_url_links
Update snippet URLs in documentation links to point to newly created snippet page
2025-04-01 10:58:54 -07:00
James Meng 7f122aeed2 Fix snippet URLs in documentation links 2025-03-31 17:08:31 -07:00
aa1640035f Always stringify properties in all array filters (#1936)
* Always stringify sum property.

* Add test

* always stringify properties in all array filters

* fix syntax error

* up version

---------

Co-authored-by: Dominic Petrick <[email protected]>
2025-03-19 13:50:01 -04:00
Ian Ker-SeymerandGitHub f5d6a36574 Stringify properties before filtering (#1929) 2025-03-17 17:43:57 -04:00
Ian Ker-SeymerandGitHub c5711c095f Improve docs of date filter (#1920) 2025-03-13 17:36:29 -04:00
James MengandGitHub 284f5fb647 Merge pull request #1928 from Shopify/jm/add_ld_link_doc_tag
Add a link to the LiquidDoc tooling reference in `doc` tag documentation
2025-03-13 09:48:40 -07:00
James MengandGitHub 21432928d0 Use full relative path for hyperlink 2025-03-13 09:47:57 -07:00
James Meng 1783c0c084 Add a link to the LiquidDoc reference in doc tag documentation comment 2025-03-12 15:31:28 -07:00
Guilherme CarreiroandGuilherme Carreiro e38f730c00 Update LiquidDoc documentation 2025-03-07 18:59:59 +01:00
2d0442798b chore: Add RUBYOPT configuration to the tests (#1859)
Co-authored-by: Ian Ker-Seymer <[email protected]>
2025-02-26 14:28:17 -05:00
Guilherme CarreiroandGuilherme Carreiro 6453a0ea48 Implement nodelist in the Doc tag so it may be visited 2025-02-26 13:14:39 +01:00
Guilherme CarreiroandGuilherme Carreiro a398b4cc74 Fix History.md 2025-02-25 08:50:46 +01:00
Guilherme CarreiroandGuilherme Carreiro cca9fe99cf Bump version to 5.8.0 2025-02-25 08:50:46 +01:00
Guilherme CarreiroandGuilherme Carreiro 17d327988d Rename {% doc %} constant strictly validates the abscense of args 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro f643af4bac Update the implementation to make {% doc %} as strict as {% raw %} 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro ae8a0a86ac Remove misleading unit test (thank you, @EvilGenius13) 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro b439d0da53 Update {% doc %} to no longer support nested tags (as {% comment %} does) 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro 16592cfb8f Add support to LiquidDoc with the new {% doc %} tag 2025-02-20 12:37:09 +01:00
Chris AtLeeandGitHub da4afd4156 Merge pull request #1905 from Shopify/catlee/invalid_utf8
Raise SyntaxError on invalid UTF8 strings in lexer/tokenizer
2025-02-13 09:24:11 -05:00
Chris AtLeeandGitHub 1bb3091208 Merge pull request #1909 from Shopify/catlee/5.7.3
Bump version to 5.7.3
2025-02-13 09:22:47 -05:00
Max StoiberandGitHub 040801b32c Fix array has filters referring to some (#1910) 2025-02-12 17:37:49 +01:00
Chris AtLee 550135c0b9 Raise SyntaxError on invalid UTF8 strings in lexer/tokenizer 2025-02-11 14:23:15 -05:00
Chris AtLee aec966eed7 Bump version to 5.7.3 2025-02-11 14:21:14 -05:00
Michael GoandGitHub bfe29e11be Merge pull request #1907 from Shopify/nested-properties
Fix array filters to not support nested properties
2025-01-31 12:35:03 -04:00
Guilherme Carreiro f9454d8cf3 Fix array filters to not support nested properties 2025-01-31 13:53:17 +01:00
Guilherme CarreiroandGuilherme Carreiro 8dd9279265 Fix release date on History.md 2025-01-24 15:34:48 +01:00
Guilherme CarreiroandGuilherme Carreiro bf1419b8ac Apply the same fix for find_index and has 2025-01-24 08:39:01 +01:00
Guilherme CarreiroandGuilherme Carreiro 5718c4cee2 Fix the find filter to return nil when filtering empty arrays 2025-01-24 08:39:01 +01:00
Guilherme CarreiroandGitHub b0dbc62696 Fix bundle exec rake example (#1900)
* Fix `bundle exec rake example`

* Move 'webrick' from 'benchmark/test' to 'development'
2025-01-23 08:21:38 +01:00
03aafa974c Bump to 5.7.0 (#1894)
* Bump to 5.7.0

* Update 'History.md'

* Update 'Releasing' steps

* Update CONTRIBUTING.md

Co-authored-by: Gray Gilmore <[email protected]>

* Update History.md

Co-authored-by: Ian Ker-Seymer <[email protected]>

---------

Co-authored-by: Gray Gilmore <[email protected]>
Co-authored-by: Ian Ker-Seymer <[email protected]>
2025-01-17 12:10:18 +01:00
Ian Ker-SeymerandGitHub 6372289ba3 Ensure we use InputIterator#each when in join filter (#1898) 2025-01-16 11:36:36 -05:00
Ian Ker-SeymerandGitHub 0ec52a40b5 Use Liquid::Utils.to_s for join filter (#1897) 2025-01-16 11:21:58 -05:00
Ian Ker-SeymerandGitHub 74af735f0e Allow for custom < Hash classes to override #to_s (#1896) 2025-01-16 11:16:13 -05:00
4b65a28722 Implement logic for stringify Hashes to keep compat with 3.4 (#1892)
* Exploring

* Bump to v5.6.5

---------

Co-authored-by: Dominic Petrick <[email protected]>
2025-01-15 16:36:53 -05:00
ecf25ea83d Add the "Releasing" section to CONTRIBUTING.md (#1891)
* Add 'Releasing' workflow to 'CONTRIBUTING.md'

* Update CONTRIBUTING.md

Co-authored-by: Michael Go <[email protected]>

---------

Co-authored-by: Michael Go <[email protected]>
2025-01-14 19:07:11 +01:00
6909570f8e Add find, find_index, has, and reject filters to arrays (#1869)
* Add reject filter #1573

* Add deep search for filter taking in properties #1749

* Update branch with main

* Add `find`, `find_index`, `has`, and `reject` filters to arrays

* Refactor: avoid usage of public_send

---------

Co-authored-by: Anders Søgaard <[email protected]>
Co-authored-by: Anders Søgaard <[email protected]>
2025-01-14 10:33:31 +01:00
Michael GoandGitHub cd9971579f Merge pull request #1889 from Shopify/default-variable-lookup-string-scanner
Add default string_scanner to `Liquid::VariableLookup.parse`
2025-01-13 23:28:11 -04:00
Ian Ker-Seymer 8e37c5e18b Bump to v5.6.4 2025-01-13 21:57:52 -05:00
Ian Ker-Seymer b3f9639e7d Add default string_scanner to Liquid::VariableLookup.parse 2025-01-13 21:56:39 -05:00
Michael GoandGitHub fe3da0e17d Merge pull request #1887 from Shopify/remove-lru-redux
remove lru-redux
2025-01-13 18:37:17 -04:00
Michael Go e200c4544b remove expression caching while rendering 2025-01-13 18:36:30 -04:00
Michael Go 7124540563 remove lru-redux 2025-01-13 18:27:56 -04:00
Michael GoandGitHub 0558bd12c4 Merge pull request #1886 from Shopify/fix-parsing-float-with-leading-point
float has to start with a digit
2025-01-13 17:17:15 -04:00
Michael Go 0639a094a8 bump version to 5.6.2 2025-01-13 17:16:33 -04:00
Michael Go 58777bcd93 float has to start with a digit 2025-01-13 17:13:27 -04:00
Michael GoandGitHub 323951b36f Merge pull request #1844 from Shopify/fast-expression-parse
Faster Expression parser / Tokenizer with StringScanner
2025-01-10 15:34:06 -04:00
Benjamin SteinandGitHub a5b91e83ae Update README.md with default environment (#1879)
Smaller projects, especially when upgrading from earlier versions, may not need different environment scopes, so just adding to the docs.
2025-01-10 14:28:46 -05:00
Michael Go 10114b333e minor version bump 2025-01-10 15:28:02 -04:00
Michael Go a07ae90523 use Ruby Hash as default expression cache 2025-01-10 15:07:57 -04:00
Michael Go 2e236b0a0e fix expression cache to be compatible with Ruby Hash and LruCache 2025-01-10 14:58:23 -04:00
Michael Go bd05dfbd1c use lru_redux getset for simplicity 2025-01-10 14:41:35 -04:00
Michael Go 2a5ecf068f remove lru_redux from Gemfile 2025-01-10 14:38:50 -04:00
Michael Go 0b8e30b819 update unit test to be compatible with new source_location format 2025-01-10 14:37:54 -04:00
Michael Go 97cada61f9 remove Expression2 strscan workaround 2025-01-10 14:23:57 -04:00
Michael Go 7c9b77e8cf remove StringScanner version check in Lexer and Tokenizer 2025-01-07 14:51:29 -04:00
Michael Go d48708ae46 skip expression caching with Expression1 2025-01-07 14:32:46 -04:00
Michael Go 42d822bda9 users can provide optional expression cache 2025-01-07 14:32:46 -04:00
Michael Go 2c7d686690 adding caching to context variable lookup 2025-01-07 14:32:46 -04:00
Michael Go 313d01706a code styling 2025-01-07 14:32:46 -04:00
Michael Go e9f86724f6 code styling 2025-01-07 14:32:46 -04:00
Michael Go 0a17c15289 freeze '-' VariableLookup in Expression 2025-01-07 14:32:46 -04:00
Michael Go 04bd9dbe90 refactor Lexer to only take StringScanner 2025-01-07 14:32:46 -04:00
Michael Go 253ec81b56 don't add a breaking change to Parser constructor 2025-01-07 14:32:46 -04:00
Michael Go e05719fb82 use Kernel Integer parsing for simple Integer expression 2025-01-07 14:32:46 -04:00
Michael Go ac374a208f freeze Expression2 parse results 2025-01-07 14:32:46 -04:00
Michael Go 91be3dd75e keep the expression parse caching per document parsing 2025-01-07 14:32:46 -04:00
Michael Go 40191022e8 initialize StringScanner with input for default 2025-01-07 14:32:46 -04:00
Michael Go 71506ad54e avoid setting up StringScanner for parsing simple numbers 2025-01-07 14:32:46 -04:00
Michael Go cef64e277e early match number expressions with Regex 2025-01-07 14:32:46 -04:00
Michael Go 7c592c1c00 store StringScanner in ParseContext and reuse it through parsing 2025-01-07 14:32:45 -04:00
Michael Go 002e4caea7 refactor Lexer to be static class function 2025-01-07 14:32:07 -04:00
Michael Go 3c16c27ee1 add StringScannerPool for thread safety 2025-01-07 14:32:07 -04:00
Michael Go eff5c5de8e code clean up 2025-01-07 14:32:07 -04:00
Michael Go 19adfbd863 don't force strscan 3.1 2025-01-07 14:32:07 -04:00
Michael Go a672f3836c don't cache literals for Expression parser 2025-01-07 14:32:07 -04:00
Michael Go 92fa334192 don't cache string expressions to be more perfomant 2025-01-07 14:32:07 -04:00
Michael Go 7c8a269b4d fix cycle tag not resetting 2025-01-07 14:32:07 -04:00
Michael Go 0b1dc295ff fix quirky negative sign expression markup parsing 2025-01-07 14:32:07 -04:00
Michael Go 1de6025362 decrease the size of Expression2 LRU cache to store 5 themes 2025-01-07 14:32:07 -04:00
Michael Go 8ecb703d4d use StringScanner to improve Expression Parsing and Tokenizer 2025-01-07 14:32:06 -04:00
Ian Ker-SeymerandGitHub b4667adadf Bump to v5.6.0 (#1876) 2024-12-19 15:01:16 -05:00
Bahar PourazarandGitHub 94e02d765f Merge pull request #1874 from Shopify/bp/bump-version
Bump version patch
2024-12-17 17:39:35 -05:00
Bahar Pourazar 60701f865d Bump version patch 2024-12-17 17:37:00 +00:00
Bahar Pourazar 0c49dd592f bring back to_s.to_str 2024-12-17 17:37:00 +00:00
Bahar PourazarandGitHub c3ac0e0127 Merge pull request #1873 from Shopify/bp/tokenizer-fix
Fix bug in tokenizer with nil source value
2024-12-17 11:43:16 -05:00
Bahar Pourazar a0a4307e7d Fix bug in tokenizer with nil value 2024-12-17 15:34:13 +00:00
Ian Ker-SeymerandGitHub fdd8c714b2 Stop testing against liquid-c (#1868)
* Stop testing against `liquid-c`

* Bump to `v5.6.0.rc2`
2024-12-11 12:23:50 -05:00
Ian Ker-SeymerandGitHub 63583ffe5b Write one value at a time for array variables (#1863)
* Write one value at a time for array variables

* Handle recursive array
2024-12-11 10:16:58 -05:00
Benjamin SehlandGitHub 9a06cedbba Merge pull request #1634 from tjoyal/patch-1
Update homepage url
2024-12-11 09:20:15 -05:00
Ian Ker-Seymer 42b6763546 Bump to v5.6.0.rc1 2024-11-04 15:26:56 -05:00
Michael GoandGitHub e5d18c83bb Merge pull request #1848 from Shopify/env-warn-cleanup
clean up all warnings by using new Environment
2024-11-04 16:17:17 -04:00
Michael Go c77ff68573 clean up all warnings by using new Environment 2024-11-04 16:15:05 -04:00
Ian Ker-SeymerandGitHub b0cba0bfd2 Remove Liquid.cache_classes option (#1847) 2024-11-04 14:41:56 -05:00
Michael GoandGitHub 8d8661349a Merge pull request #1843 from Shopify/empty-array
avoid allocating new empty array
2024-11-04 15:36:46 -04:00
Thierry JoyalandGitHub 347a2418c4 Update homepage url
`http://www.liquidmarkup.org` is `http` 
`http://www.liquidmarkup.org` redirects to `https://shopify.github.io/liquid/`
`https://www.liquidmarkup.org` can’t provide a secure connection (ERR_SSL_PROTOCOL_ERROR)
2022-10-03 16:52:22 -04:00
102 changed files with 5869 additions and 632 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
+47 -10
View File
@@ -1,5 +1,5 @@
name: Liquid
on: [push, pull_request]
on: [push]
env:
BUNDLE_JOBS: 4
@@ -9,28 +9,65 @@ 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 } # latest
- { ruby: ruby-head, allowed-failure: false }
name: Test Ruby ${{ matrix.entry.ruby }}
- { ruby: 3.3, allowed-failure: false } # minimum supported
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false } # latest stable
- {
ruby: 4.0,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
- { ruby: truffleruby, allowed-failure: false }
# Head can have failures due to being in development
- { ruby: head, allowed-failure: true }
- {
ruby: head,
allowed-failure: true,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: head, allowed-failure: true, rubyopt: "--yjit" }
- { ruby: head, allowed-failure: true, rubyopt: "--zjit" }
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
bundler: latest
- run: bundle exec rake
continue-on-error: ${{ matrix.entry.allowed-failure }}
env:
RUBYOPT: ${{ matrix.entry.rubyopt }}
spec:
runs-on: ubuntu-latest
env:
BUNDLE_WITH: spec
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
bundler-cache: true
bundler: latest
- name: Run liquid-spec for all adapters
run: |
for adapter in spec/*.rb; do
echo "=== Running $adapter ==="
bundle exec liquid-spec run "$adapter" --no-max-failures
done
memory_profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
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.3.4
4.0.2
+8
View File
@@ -26,3 +26,11 @@
* If it makes sense, add tests for your code and/or run a performance benchmark
* Make sure all tests pass (`bundle exec rake`)
* Create a pull request
## Releasing
* Bump the version in `lib/liquid/version.rb`
* Update the `History.md` file
* Open a PR like [this one](https://github.com/Shopify/liquid/pull/1894) and merge it to `main`
* Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new)
+15 -8
View File
@@ -13,18 +13,25 @@ group :benchmark, :test do
gem 'benchmark-ips'
gem 'memory_profiler'
gem 'terminal-table'
gem "lru_redux"
install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do
gem 'stackprof'
end
end
group :test do
gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'rubocop-performance', require: false
platform :mri, :truffleruby do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'main'
end
group :development do
gem "webrick"
end
group :test do
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'
gem 'activesupport', require: false
end
+94
View File
@@ -1,5 +1,99 @@
# Liquid Change Log
## 5.13.0
* Add TruffleRuby in CI [Benoit Daloze]
* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze]
* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy]
* Add strict2_parse to assign and capture tags by [Alok Swamy]
* Add strict2_parse to increment and decrement tags by [Alok Swamy]
* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer]
* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro]
* Fix `SelfDrop` equality [Guilherme Carreiro]
* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer]
## 5.11.0
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
## 5.10.0
* Introduce support for Inline Snippets [Julia Boutin]
## 5.9.0
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]
## 5.8.7
* Expose body content in the `Doc` tag [James Meng]
## 5.8.1
* Fix `{% doc %}` tag to be visitable [Guilherme Carreiro]
## 5.8.0
* Introduce the new `{% doc %}` tag [Guilherme Carreiro]
## 5.7.3
* Raise Liquid::SyntaxError when parsing invalidly encoded strings [Chris AtLee]
## 5.7.2 2025-01-31
* Fix array filters to not support nested properties [Guilherme Carreiro]
## 5.7.1 2025-01-24
* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays [Guilherme Carreiro]
* Fix the `has` filter to return `false` when filtering empty arrays [Guilherme Carreiro]
## 5.7.0 2025-01-16
### Features
* Add `find`, `find_index`, `has`, and `reject` filters to arrays [Guilherme Carreiro]
* Compatibility with Ruby 3.4 [Ian Ker-Seymer]
## 5.6.4 2025-01-14
### Fixes
* Add a default `string_scanner` to avoid errors with `Liquid::VariableLookup.parse("foo.bar")` [Ian Ker-Seymer]
## 5.6.3 2025-01-13
* Remove `lru_redux` dependency [Michael Go]
## 5.6.2 2025-01-13
### Fixes
* Preserve the old behavior of requiring floats to start with a digit [Michael Go]
## 5.6.1 2025-01-13
### Performance improvements
* Faster Expression parser / Tokenizer with StringScanner [Michael Go]
## 5.6.0 2024-12-19
### Architectural changes
* Added new `Environment` class to manage configuration and state that was previously stored in `Template` [Ian Ker-Seymer]
* Moved tag registration from `Template` to `Environment` [Ian Ker-Seymer]
* Removed `StrainerFactory` in favor of `Environment`-based strainer creation [Ian Ker-Seymer]
* Consolidated standard tags into a new `Tags` module with `STANDARD_TAGS` constant [Ian Ker-Seymer]
### Performance improvements
* Optimized `Lexer` with a new `Lexer2` implementation using jump tables for faster tokenization, requires Ruby 3.4 [Ian Ker-Seymer]
* Improved variable rendering with specialized handling for different types [Michael Go]
* Reduced array allocations by using frozen empty constants [Michael Go]
### API changes
* Deprecated several `Template` class methods in favor of `Environment` methods [Ian Ker-Seymer]
* Added deprecation warnings system [Ian Ker-Seymer]
* Changed how filters and tags are registered to use Environment [Ian Ker-Seymer]
### Fixes
* Fixed table row handling of break interrupts [Alex Coco]
* Improved variable output handling for arrays [Ian Ker-Seymer]
* Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar]
## 5.5.0 2024-03-21
Please reference the GitHub release for more information.
+6 -6
View File
@@ -91,7 +91,7 @@ Liquid::Template.parse(<<~LIQUID, environment: email_environment)
LIQUID
```
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage.
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
### Error Modes
@@ -99,14 +99,14 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
it very hard to debug and can lead to unexpected behaviour.
Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
Liquid also comes with different parsers that can be used when editing templates to give better error messages
when templates are invalid. You can enable this new parser like this:
```ruby
Liquid::Environment.default.error_mode = :strict
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
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`:
+46 -8
View File
@@ -33,7 +33,7 @@ task :rubocop do
end
end
desc('runs test suite with both strict and lax parsers')
desc('runs test suite with lax, strict, and strict2 parsers')
task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke
@@ -42,9 +42,11 @@ task :test do
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_C'] = '1'
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
@@ -52,6 +54,10 @@ task :test do
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
end
@@ -73,7 +79,7 @@ end
namespace :benchmark do
desc "Run the liquid benchmark with lax parsing"
task :run do
task :lax do
ruby "./performance/benchmark.rb lax"
end
@@ -82,11 +88,35 @@ namespace :benchmark do
ruby "./performance/benchmark.rb strict"
end
desc "Run the liquid benchmark with strict2 parsing"
task :strict2 do
ruby "./performance/benchmark.rb strict2"
end
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
task run: [:lax, :strict, :strict2]
desc "Run unit benchmarks"
task :unit do
Dir["./performance/unit/*_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
namespace :unit do
task :all do
Dir["./performance/unit/*_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end
task :lexer do
Dir["./performance/unit/lexer_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end
task :expression do
Dir["./performance/unit/expression_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end
end
end
@@ -118,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
Executable
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'bundler/setup'
require 'liquid'
class VirtualFileSystem
def initialize
snippet_1 = <<~LIQUID
<h1>
{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
</h1>
LIQUID
snippet_2 = <<~LIQUID
{%- for i in (1..5) -%}
> {{ i }}
{%- endfor -%}
LIQUID
@templates = {
'snippet-1' => snippet_1,
'snippet-2' => snippet_2,
}
end
def read_template_file(key)
@templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'")
end
end
def source
File.read(ARGV[0])
rescue StandardError
'Usage: bin/render example/server/templates/index.liquid'
end
def assigns
{
'date' => Time.now,
}
end
puts Liquid::Template
.parse(source, error_mode: :strict2)
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
.render(assigns)
+72
View File
@@ -0,0 +1,72 @@
# Recording and replaying renders
`Liquid::TemplateRecorder` captures successful template renders so they can be
replayed without the application's file system or Drop implementations.
Recording does not wrap or replace assigns, so the recorded render has the same
semantics as a normal render.
```ruby
Liquid::TemplateRecorder.record("render.json") do
template = Liquid::Template.parse(source)
template.render!(assigns)
end
replayer = Liquid::TemplateRecorder.replay_from("render.json", mode: :verify)
replayer.render # raises if the output changed
```
A recording contains the root template, every parsed partial, partial contents,
plain Hash/Array values resolved by the template, properties actually read from `Liquid::Drop` objects,
filter-call diagnostics, engine options, and the rendered output. Drop instance
variables are never inspected. An unsupported Ruby object raises
`Liquid::TemplateRecorder::SerializationError` rather than silently producing a
recording that cannot be replayed.
## Storage formats
A `.json` destination is written atomically after the recording block succeeds.
It contains a session with every render performed by the block.
A `.jsonl` destination is append-only. Each successful top-level render is one
compact, self-contained JSON line. This is the recommended format for production
sampling: a process failure can lose at most the render being written, writers
are serialized with `flock`, and a recording can be replayed by index.
A destination may instead be any writer object responding to `write(record)`. The
writer receives one self-contained recording Hash per successful render. Liquid
does not own or close injected writers, so applications can publish records to
Kafka, object storage, or another transport without coupling that transport to
the recorder.
Pass `on_error:` to keep serialization or sink failures out of the render path;
the callback receives the error and should not raise.
```ruby
Liquid::TemplateRecorder.record(kafka_writer) do
template.render!(assigns)
end
```
```ruby
Liquid::TemplateRecorder.replay_from("renders.jsonl") # last render
Liquid::TemplateRecorder.replay_from("renders.jsonl", index: 0) # first render
Liquid::TemplateRecorder.records("renders.jsonl") # inspect all
```
Compression is intentionally separate from the schema. In particular, one
long-lived compressed stream makes appending, recovery, and selecting a render
harder. Compress rotated `.jsonl` files with the storage system of your choice;
a future compressed writer can use one independent frame per record without a
schema change.
Recording sessions are thread-local. Nested sessions in the same thread are
rejected. Existing application register names and the one-argument
`FileSystem#read_template_file` API remain unchanged.
## Replay modes
* `:compute` runs filters normally. Pass application filters with
`replayer.render(filters: MyFilters)`.
* `:strict` returns each exact recorded filter result and rejects a changed
filter sequence. This can replay application-specific or nondeterministic
filters without loading their implementations.
* `:verify` computes normally and raises when the final output differs.
+6 -5
View File
@@ -21,6 +21,8 @@
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
require "strscan"
module Liquid
FilterSeparator = /\|/
ArgumentSeparator = ','
@@ -44,9 +46,7 @@ module Liquid
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
singleton_class.send(:attr_accessor, :cache_classes)
self.cache_classes = true
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
end
require "liquid/version"
@@ -65,14 +65,13 @@ require 'liquid/lexer'
require 'liquid/parser'
require 'liquid/i18n'
require 'liquid/drop'
require 'liquid/self_drop'
require 'liquid/tablerowloop_drop'
require 'liquid/forloop_drop'
require 'liquid/extensions'
require 'liquid/errors'
require 'liquid/interrupts'
require 'liquid/strainer_template'
require 'liquid/strainer_factory'
require 'liquid/expression'
require 'liquid/context'
require 'liquid/tag'
require 'liquid/block_body'
@@ -81,6 +80,7 @@ require 'liquid/variable'
require 'liquid/variable_lookup'
require 'liquid/range_lookup'
require 'liquid/resource_limits'
require 'liquid/expression'
require 'liquid/template'
require 'liquid/condition'
require 'liquid/utils'
@@ -90,3 +90,4 @@ require 'liquid/partial_cache'
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
require "liquid/template_recorder"
+8 -1
View File
@@ -84,10 +84,15 @@ module Liquid
# @api private
def self.render_node(context, output, node)
recorder = TemplateRecorder.current
tag_call = recorder&.begin_tag_render(node, context)
output_start = output.length
node.render_to_output_buffer(context, output)
rescue => exc
blank_tag = !node.instance_of?(Variable) && node.blank?
rescue_render_node(context, output, node.line_number, exc, blank_tag)
ensure
recorder&.finish_tag_render(tag_call, output[output_start..]) if output_start
end
# @api private
@@ -99,7 +104,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
+57 -14
View File
@@ -48,8 +48,8 @@ module Liquid
@@operators
end
def self.parse_expression(parse_context, markup)
@@method_literals[markup] || parse_context.parse_expression(markup)
def self.parse_expression(parse_context, markup, safe: false)
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
end
attr_reader :attachment, :child_condition
@@ -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
+27 -3
View File
@@ -40,6 +40,10 @@ module Liquid
@global_filter = nil
@disabled_tags = {}
# Instead of constructing new StringScanner objects for each Expression parse,
# we recycle the same one.
@string_scanner = StringScanner.new("")
@registers.static[:cached_partials] ||= {}
@registers.static[:file_system] ||= environment.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new
@@ -176,11 +180,20 @@ module Liquid
# Example:
# products == empty #=> products.empty?
def [](expression)
evaluate(Expression.parse(expression))
evaluate(Expression.parse(expression, @string_scanner))
end
def key?(key)
self[key] != nil
find_variable(key, raise_on_not_found: false) != nil
end
# Checks whether a variable is defined in any scope, including nil-valued keys.
# Unlike #key?, this uses Hash#key? so that variables explicitly set to nil
# are still considered defined.
def variable_defined?(key)
@scopes.any? { |s| s.key?(key) } ||
@environments.any? { |e| e.key?(key) } ||
@static_environments.any? { |e| e.key?(key) }
end
def evaluate(object)
@@ -193,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=)
@@ -206,6 +228,8 @@ module Liquid
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
recorder = @registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
recorder&.emit_variable_read(key, liquid_variable)
liquid_variable
end
+6 -2
View File
@@ -31,17 +31,21 @@ 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
# called by liquid to invoke a drop
def invoke_drop(method_or_key)
if self.class.invokable?(method_or_key)
result = if self.class.invokable?(method_or_key)
send(method_or_key)
else
liquid_method_missing(method_or_key)
end
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
recorder&.emit_drop_read(self, method_or_key, result)
result
end
def key?(_name)
+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 :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.
+104 -22
View File
@@ -2,6 +2,8 @@
module Liquid
class Expression
SELF = 'self'
LITERALS = {
nil => nil,
'nil' => nil,
@@ -10,37 +12,117 @@ module Liquid
'true' => true,
'false' => false,
'blank' => '',
'empty' => ''
'empty' => '',
# in lax mode, minus sign can be a VariableLookup
# For simplicity and performace, we treat it like a literal
'-' => VariableLookup.parse("-", nil).freeze,
}.freeze
INTEGERS_REGEX = /\A(-?\d+)\z/
FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
DOT = ".".ord
ZERO = "0".ord
NINE = "9".ord
DASH = "-".ord
# Use an atomic group (?>...) to avoid pathological backtracing from
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
INTEGER_REGEX = /\A(-?\d+)\z/
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
def self.parse(markup)
return nil unless markup
markup = markup.strip
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
class << self
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
parse(parser.expression, ss, cache)
end
case markup
when INTEGERS_REGEX
Regexp.last_match(1).to_i
when RANGES_REGEX
RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2))
when FLOATS_REGEX
Regexp.last_match(1).to_f
else
if LITERALS.key?(markup)
LITERALS[markup]
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
markup = markup.strip # markup can be a frozen string
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
elsif LITERALS.key?(markup)
return LITERALS[markup]
end
# Cache only exists during parsing
if cache
return cache[markup] if cache.key?(markup)
cache[markup] = inner_parse(markup, ss, cache).freeze
else
VariableLookup.parse(markup)
inner_parse(markup, ss, nil).freeze
end
end
def inner_parse(markup, ss, cache)
if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX
return RangeLookup.parse(
Regexp.last_match(1),
Regexp.last_match(2),
ss,
cache,
)
end
if (num = parse_number(markup, ss))
num
else
VariableLookup.parse(markup, ss, cache)
end
end
def parse_number(markup, ss)
# check if the markup is simple integer or float
case markup
when INTEGER_REGEX
return Integer(markup, 10)
when FLOAT_REGEX
return markup.to_f
end
ss.string = markup
# the first byte must be a digit or a dash
byte = ss.scan_byte
return false if byte != DASH && (byte < ZERO || byte > NINE)
if byte == DASH
peek_byte = ss.peek_byte
# if it starts with a dash, the next byte must be a digit
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
end
# The markup could be a float with multiple dots
first_dot_pos = nil
num_end_pos = nil
while (byte = ss.scan_byte)
return false if byte != DOT && (byte < ZERO || byte > NINE)
# we found our number and now we are just scanning the rest of the string
next if num_end_pos
if byte == DOT
if first_dot_pos.nil?
first_dot_pos = ss.pos
else
# we found another dot, so we know that the number ends here
num_end_pos = ss.pos - 1
end
end
end
num_end_pos = markup.length if ss.eos?
if num_end_pos
# number ends with a number "123.123"
markup.byteslice(0, num_end_pos).to_f
else
# number ends with a dot "123."
markup.byteslice(0, first_dot_pos).to_f
end
end
end
+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
+64 -122
View File
@@ -1,66 +1,7 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Lexer1
SPECIALS = {
'|' => :pipe,
'.' => :dot,
':' => :colon,
',' => :comma,
'[' => :open_square,
']' => :close_square,
'(' => :open_round,
')' => :close_round,
'?' => :question,
'-' => :dash,
}.freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
SINGLE_STRING_LITERAL = /'[^\']*'/
DOUBLE_STRING_LITERAL = /"[^\"]*"/
STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
NUMBER_LITERAL = /-?\d+(\.\d+)?/
DOTDOT = /\.\./
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
WHITESPACE_OR_NOTHING = /\s*/
def initialize(input)
@ss = StringScanner.new(input)
end
def tokenize
@output = []
until @ss.eos?
@ss.skip(WHITESPACE_OR_NOTHING)
break if @ss.eos?
tok = if (t = @ss.scan(COMPARISON_OPERATOR))
[:comparison, t]
elsif (t = @ss.scan(STRING_LITERAL))
[:string, t]
elsif (t = @ss.scan(NUMBER_LITERAL))
[:number, t]
elsif (t = @ss.scan(IDENTIFIER))
[:id, t]
elsif (t = @ss.scan(DOTDOT))
[:dotdot, t]
else
c = @ss.getch
if (s = SPECIALS[c])
[s, c]
else
raise SyntaxError, "Unexpected character #{c}"
end
end
@output << tok
end
@output << [:end_of_string]
end
end
class Lexer2
class Lexer
CLOSE_ROUND = [:close_round, ")"].freeze
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
@@ -92,6 +33,7 @@ module Liquid
SINGLE_COMPARISON_TOKENS = [].tap do |table|
table["<".ord] = COMPARISON_LESS_THAN
table[">".ord] = COMPARISON_GREATER_THAN
table.freeze
end
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
@@ -103,18 +45,17 @@ module Liquid
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table.freeze
end
table.freeze
end
COMPARISON_JUMP_TABLE = [].tap do |table|
table["<".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN }
sub_table.freeze
end
table[">".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN }
sub_table.freeze
end
table.freeze
@@ -157,81 +98,82 @@ module Liquid
table.freeze
end
def initialize(input)
@ss = StringScanner.new(input)
end
# rubocop:disable Metrics/BlockNesting
def tokenize
@output = []
class << self
def tokenize(ss)
output = []
until @ss.eos?
@ss.skip(WHITESPACE_OR_NOTHING)
until ss.eos?
ss.skip(WHITESPACE_OR_NOTHING)
break if @ss.eos?
break if ss.eos?
start_pos = @ss.pos
peeked = @ss.peek_byte
start_pos = ss.pos
peeked = ss.peek_byte
if (special = SPECIAL_TABLE[peeked])
@ss.scan_byte
# Special case for ".."
if special == DOT && @ss.peek_byte == DOT_ORD
@ss.scan_byte
@output << DOTDOT
elsif special == DASH
# Special case for negative numbers
if (peeked_byte = @ss.peek_byte) && NUMBER_TABLE[peeked_byte]
@ss.pos -= 1
@output << [:number, @ss.scan(NUMBER_LITERAL)]
if (special = SPECIAL_TABLE[peeked])
ss.scan_byte
# Special case for ".."
if special == DOT && ss.peek_byte == DOT_ORD
ss.scan_byte
output << DOTDOT
elsif special == DASH
# Special case for negative numbers
if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte]
ss.pos -= 1
output << [:number, ss.scan(NUMBER_LITERAL)]
else
output << special
end
else
@output << special
output << special
end
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
ss.scan_byte
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
else
raise_syntax_error(start_pos, ss)
end
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
ss.scan_byte
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
else
output << SINGLE_COMPARISON_TOKENS[peeked]
end
else
@output << special
end
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
@ss.scan_byte
if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
@output << found
@ss.scan_byte
else
raise_syntax_error(start_pos)
end
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
@ss.scan_byte
if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
@output << found
@ss.scan_byte
else
@output << SINGLE_COMPARISON_TOKENS[peeked]
end
else
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
if type && (t = @ss.scan(pattern))
# Special case for "contains"
@output << if type == :id && t == "contains" && @output.last&.first != :dot
COMPARISON_CONTAINS
if type && (t = ss.scan(pattern))
# Special case for "contains"
output << if type == :id && t == "contains" && output.last&.first != :dot
COMPARISON_CONTAINS
else
[type, t]
end
else
[type, t]
raise_syntax_error(start_pos, ss)
end
else
raise_syntax_error(start_pos)
end
end
# rubocop:enable Metrics/BlockNesting
output << EOS
rescue ::ArgumentError => e
if e.message == "invalid byte sequence in #{ss.string.encoding}"
raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
else
raise
end
end
# rubocop:enable Metrics/BlockNesting
@output << EOS
end
def raise_syntax_error(start_pos)
@ss.pos = start_pos
# the character could be a UTF-8 character, use getch to get all the bytes
raise SyntaxError, "Unexpected character #{@ss.getch}"
def raise_syntax_error(start_pos, ss)
ss.pos = start_pos
# the character could be a UTF-8 character, use getch to get all the bytes
raise SyntaxError, "Unexpected character #{ss.getch}"
end
end
end
Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1
end
+3
View File
@@ -2,12 +2,14 @@
errors:
syntax:
tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}"
block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}"
assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
case: "Syntax Error in 'case' - Valid syntax: case [condition]"
case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}"
case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) "
cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"
doc_invalid_nested: "Syntax Error in 'doc' - Nested doc tags are not allowed"
for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"
for_invalid_in: "For loops require an 'in' clause"
for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset"
@@ -18,6 +20,7 @@
invalid_template_encoding: "Invalid template encoding"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
tag_never_closed: "'%{block_name}' tag was never closed"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
unexpected_else: "%{block_name} tag does not expect 'else' tag"
+41 -4
View File
@@ -12,6 +12,18 @@ module Liquid
@locale = @template_options[:locale] ||= I18n.new
@warnings = []
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
# This StringScanner will be shared by all of them
@string_scanner = StringScanner.new("")
@expression_cache = if options[:expression_cache].nil?
{}
elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=)
options[:expression_cache]
elsif options[:expression_cache]
{}
end
self.depth = 0
self.partial = false
end
@@ -24,12 +36,37 @@ module Liquid
Liquid::BlockBody.new
end
def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false)
Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag)
def new_parser(input)
@string_scanner.string = input
Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid)
end
def parse_expression(markup)
Expression.parse(markup)
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
Tokenizer.new(
source: source,
string_scanner: @string_scanner,
line_number: start_line_number,
for_liquid_tag: for_liquid_tag,
)
end
def safe_parse_expression(parser)
Expression.safe_parse(parser, @string_scanner, @expression_cache)
end
def parse_expression(markup, safe: false)
if !safe && @error_mode == :strict2
# parse_expression is a widely used API. To maintain backward
# compatibility while raising awareness about strict2 parser standards,
# the safe flag supports API users make a deliberate decision.
#
# In strict2 mode, markup MUST come from a string returned by the parser
# (e.g., parser.expression). We're not calling the parser here to
# prevent redundant parser overhead.
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
end
Expression.parse(markup, @string_scanner, @expression_cache)
end
def partial=(value)
+7 -3
View File
@@ -2,10 +2,11 @@
module Liquid
class Parser
def initialize(input)
l = Lexer.new(input)
@tokens = l.tokenize
def initialize(input, reject_bare_brackets: false)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location
@reject_bare_brackets = reject_bare_brackets
end
def jump(point)
@@ -53,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)
+36 -3
View File
@@ -2,10 +2,25 @@
module Liquid
module ParserSwitching
# Do not use this.
#
# It's basically doing the same thing the {#parse_with_selected_parser},
# except this will try the strict parser regardless of the error mode,
# and fall back to the lax parser if the error mode is lax or warn,
# except when in strict2 mode where it uses the strict2 parser.
#
# @deprecated Use {#parse_with_selected_parser} instead.
def strict_parse_with_error_mode_fallback(markup)
return strict2_parse_with_error_context(markup) if strict2_mode?
strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
when :rigid
rigid_warn
raise
when :strict2
raise
when :strict
raise
when :warn
@@ -16,11 +31,13 @@ module Liquid
def parse_with_selected_parser(markup)
case parse_context.error_mode
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :rigid then rigid_warn && strict2_parse_with_error_context(markup)
when :strict2 then strict2_parse_with_error_context(markup)
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :warn
begin
strict_parse_with_error_context(markup)
strict2_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
@@ -28,8 +45,24 @@ module Liquid
end
end
def strict2_mode?
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
end
private
def rigid_warn
Deprecations.warn(':rigid', ':strict2')
end
def strict2_parse_with_error_context(markup)
strict2_parse(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
def strict_parse_with_error_context(markup)
strict_parse(markup)
rescue SyntaxError => e
+2
View File
@@ -10,6 +10,8 @@ module Liquid
file_system = context.registers[:file_system]
source = file_system.read_template_file(template_name)
recorder = context.registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
recorder&.emit_file_read(template_name, source)
parse_context.partial = true
+3 -3
View File
@@ -2,9 +2,9 @@
module Liquid
class RangeLookup
def self.parse(start_markup, end_markup)
start_obj = Expression.parse(start_markup)
end_obj = Expression.parse(end_markup)
def self.parse(start_markup, end_markup, string_scanner, cache = nil)
start_obj = Expression.parse(start_markup, string_scanner, cache)
end_obj = Expression.parse(end_markup, string_scanner, cache)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
+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
+186 -68
View File
@@ -3,16 +3,24 @@
require 'cgi'
require 'base64'
require 'bigdecimal'
module Liquid
module StandardFilters
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;',
@@ -64,7 +72,7 @@ module Liquid
# @liquid_syntax string | downcase
# @liquid_return [string]
def downcase(input)
input.to_s.downcase
Utils.to_s(input).downcase
end
# @liquid_public_docs
@@ -75,7 +83,7 @@ module Liquid
# @liquid_syntax string | upcase
# @liquid_return [string]
def upcase(input)
input.to_s.upcase
Utils.to_s(input).upcase
end
# @liquid_public_docs
@@ -86,7 +94,7 @@ module Liquid
# @liquid_syntax string | capitalize
# @liquid_return [string]
def capitalize(input)
input.to_s.capitalize
Utils.to_s(input).capitalize
end
# @liquid_public_docs
@@ -97,7 +105,7 @@ module Liquid
# @liquid_syntax string | escape
# @liquid_return [string]
def escape(input)
CGI.escapeHTML(input.to_s) unless input.nil?
CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
end
alias_method :h, :escape
@@ -109,7 +117,7 @@ module Liquid
# @liquid_syntax string | escape_once
# @liquid_return [string]
def escape_once(input)
input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
end
# @liquid_public_docs
@@ -124,7 +132,7 @@ module Liquid
# @liquid_syntax string | url_encode
# @liquid_return [string]
def url_encode(input)
CGI.escape(input.to_s) unless input.nil?
CGI.escape(Utils.to_s(input)) unless input.nil?
end
# @liquid_public_docs
@@ -138,7 +146,7 @@ module Liquid
def url_decode(input)
return if input.nil?
result = CGI.unescape(input.to_s)
result = CGI.unescape(Utils.to_s(input))
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
result
@@ -152,7 +160,7 @@ module Liquid
# @liquid_syntax string | base64_encode
# @liquid_return [string]
def base64_encode(input)
Base64.strict_encode64(input.to_s)
Base64.strict_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -163,7 +171,7 @@ module Liquid
# @liquid_syntax string | base64_decode
# @liquid_return [string]
def base64_decode(input)
input = input.to_s
input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
@@ -177,7 +185,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_encode
# @liquid_return [string]
def base64_url_safe_encode(input)
Base64.urlsafe_encode64(input.to_s)
Base64.urlsafe_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -188,7 +196,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string]
def base64_url_safe_decode(input)
input = input.to_s
input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
@@ -212,14 +220,14 @@ module Liquid
if input.is_a?(Array)
input.slice(offset, length) || []
else
input.to_s.slice(offset, length) || ''
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
@@ -236,10 +244,10 @@ module Liquid
# @liquid_return [string]
def truncate(input, length = 50, truncate_string = "...")
return if input.nil?
input_str = input.to_s
input_str = Utils.to_s(input)
length = Utils.to_integer(length)
truncate_string_str = truncate_string.to_s
truncate_string_str = Utils.to_s(truncate_string)
l = length - truncate_string_str.length
l = 0 if l < 0
@@ -263,7 +271,7 @@ module Liquid
# @liquid_return [string]
def truncatewords(input, words = 15, truncate_string = "...")
return if input.nil?
input = input.to_s
input = Utils.to_s(input)
words = Utils.to_integer(words)
words = 1 if words <= 0
@@ -277,7 +285,8 @@ module Liquid
return input if wordlist.length <= words
wordlist.pop
wordlist.join(" ").concat(truncate_string.to_s)
truncate_string = Utils.to_s(truncate_string)
wordlist.join(" ").concat(truncate_string)
end
# @liquid_public_docs
@@ -288,7 +297,22 @@ module Liquid
# @liquid_syntax string | split: string
# @liquid_return [array[string]]
def split(input, pattern)
input.to_s.split(pattern.to_s)
pattern = Utils.to_s(pattern)
input = Utils.to_s(input)
input.split(pattern)
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
# @liquid_summary
# Removes leading and trailing whitespace and collapses consecutive whitespace to a single space.
# @liquid_syntax string | squish
# @liquid_return [string]
def squish(input)
return if input.nil?
Utils.to_s(input).strip.gsub(/\s+/, ' ')
end
# @liquid_public_docs
@@ -299,7 +323,8 @@ module Liquid
# @liquid_syntax string | strip
# @liquid_return [string]
def strip(input)
input.to_s.strip
input = Utils.to_s(input)
input.strip
end
# @liquid_public_docs
@@ -310,7 +335,8 @@ module Liquid
# @liquid_syntax string | lstrip
# @liquid_return [string]
def lstrip(input)
input.to_s.lstrip
input = Utils.to_s(input)
input.lstrip
end
# @liquid_public_docs
@@ -321,7 +347,8 @@ module Liquid
# @liquid_syntax string | rstrip
# @liquid_return [string]
def rstrip(input)
input.to_s.rstrip
input = Utils.to_s(input)
input.rstrip
end
# @liquid_public_docs
@@ -332,8 +359,9 @@ module Liquid
# @liquid_syntax string | strip_html
# @liquid_return [string]
def strip_html(input)
input = Utils.to_s(input)
empty = ''
result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
result = input.gsub(STRIP_HTML_BLOCKS, empty)
result.gsub!(STRIP_HTML_TAGS, empty)
result
end
@@ -346,7 +374,8 @@ module Liquid
# @liquid_syntax string | strip_newlines
# @liquid_return [string]
def strip_newlines(input)
input.to_s.gsub(/\r?\n/, '')
input = Utils.to_s(input)
input.gsub(/\r?\n/, '')
end
# @liquid_public_docs
@@ -357,6 +386,7 @@ module Liquid
# @liquid_syntax array | join
# @liquid_return [string]
def join(input, glue = ' ')
glue = Utils.to_s(glue)
InputIterator.new(input, context).join(glue)
end
@@ -424,29 +454,59 @@ module Liquid
# @liquid_syntax array | where: string, string
# @liquid_return [array[untyped]]
def where(input, property, target_value = nil)
ary = InputIterator.new(input, context)
filter_array(input, property, target_value) { |ary, &block| ary.select(&block) }
end
if ary.empty?
[]
elsif target_value.nil?
ary.select do |item|
item[property]
rescue TypeError
raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end
else
ary.select do |item|
item[property] == target_value
rescue TypeError
raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Filters an array to exclude items with a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | reject: string, string
# @liquid_return [array[untyped]]
def reject(input, property, target_value = nil)
filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) }
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Tests if any item in an array has a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | has: string, string
# @liquid_return [boolean]
def has(input, property, target_value = nil)
filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) }
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Returns the first item in an array with a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | find: string, string
# @liquid_return [untyped]
def find(input, property, target_value = nil)
filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) }
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Returns the index of the first item in an array with a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | find_index: string, string
# @liquid_return [number]
def find_index(input, property, target_value = nil)
filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) }
end
# @liquid_public_docs
@@ -543,7 +603,10 @@ module Liquid
# @liquid_syntax string | replace: string, string
# @liquid_return [string]
def replace(input, string, replacement = '')
input.to_s.gsub(string.to_s, replacement.to_s)
string = Utils.to_s(string)
replacement = Utils.to_s(replacement)
input = Utils.to_s(input)
input.gsub(string, replacement)
end
# @liquid_public_docs
@@ -554,7 +617,10 @@ module Liquid
# @liquid_syntax string | replace_first: string, string
# @liquid_return [string]
def replace_first(input, string, replacement = '')
input.to_s.sub(string.to_s, replacement.to_s)
string = Utils.to_s(string)
replacement = Utils.to_s(replacement)
input = Utils.to_s(input)
input.sub(string, replacement)
end
# @liquid_public_docs
@@ -565,9 +631,9 @@ module Liquid
# @liquid_syntax string | replace_last: string, string
# @liquid_return [string]
def replace_last(input, string, replacement)
input = input.to_s
string = string.to_s
replacement = replacement.to_s
input = Utils.to_s(input)
string = Utils.to_s(string)
replacement = Utils.to_s(replacement)
start_index = input.rindex(string)
@@ -619,7 +685,9 @@ module Liquid
# @liquid_syntax string | append: string
# @liquid_return [string]
def append(input, string)
input.to_s + string.to_s
input = Utils.to_s(input)
string = Utils.to_s(string)
input + string
end
# @liquid_public_docs
@@ -648,7 +716,9 @@ module Liquid
# @liquid_syntax string | prepend: string
# @liquid_return [string]
def prepend(input, string)
string.to_s + input.to_s
input = Utils.to_s(input)
string = Utils.to_s(string)
string + input
end
# @liquid_public_docs
@@ -659,10 +729,20 @@ module Liquid
# @liquid_syntax string | newline_to_br
# @liquid_return [string]
def newline_to_br(input)
input.to_s.gsub(/\r?\n/, "<br />\n")
input = Utils.to_s(input)
input.gsub(/\r?\n/, "<br />\n")
end
# Reformat a date using Ruby's core Time#strftime( string ) -> string
# @liquid_public_docs
# @liquid_type filter
# @liquid_category date
# @liquid_summary
# Formats a date according to a specified format string.
# @liquid_description
# This filter formats a date using various format specifiers. If the format string is empty,
# the original input is returned. If the input cannot be converted to a date, the original input is returned.
#
# The following format specifiers can be used:
#
# %a - The abbreviated weekday name (``Sun'')
# %A - The full weekday name (``Sunday'')
@@ -691,14 +771,15 @@ module Liquid
# %Y - Year with century
# %Z - Time zone name
# %% - Literal ``%'' character
#
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
# @liquid_syntax date | date: string
# @liquid_return [string]
def date(input, format)
return input if format.to_s.empty?
str_format = Utils.to_s(format)
return input if str_format.empty?
return input unless (date = Utils.to_date(input))
date.strftime(format.to_s)
date.strftime(str_format)
end
# @liquid_public_docs
@@ -709,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
@@ -720,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
@@ -918,8 +1003,27 @@ module Liquid
attr_reader :context
def filter_array(input, property, target_value, default_value = [], &block)
ary = InputIterator.new(input, context)
return default_value if ary.empty?
block.call(ary) do |item|
if target_value.nil?
item[property]
else
item[property] == target_value
end
rescue TypeError
raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end
end
def raise_property_error(property)
raise Liquid::ArgumentError, "cannot select the property '#{property}'"
raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'"
end
def apply_operation(input, operand, operation)
@@ -968,7 +1072,18 @@ module Liquid
end
def join(glue)
to_a.join(glue.to_s)
first = true
output = +""
each do |item|
if first
first = false
else
output << glue
end
output << Liquid::Utils.to_s(item)
end
output
end
def concat(args)
@@ -980,7 +1095,10 @@ module Liquid
end
def uniq(&block)
to_a.uniq(&block)
to_a.uniq do |item|
item = Utils.to_liquid_value(item)
block ? yield(item) : item
end
end
def compact
-23
View File
@@ -1,23 +0,0 @@
# frozen_string_literal: true
module Liquid
# StrainerFactory is the factory for the filters system.
module StrainerFactory
extend self
def add_global_filter(filter, environment = Environment.default)
Deprecations.warn("StrainerFactory.add_global_filter", "Environment#register_filter")
environment.register_filter(filter)
end
def create(context, filters = Const::EMPTY_ARRAY, environment = Environment.default)
Deprecations.warn("StrainerFactory.create", "StrainerFactory.create_strainer")
environment.create_strainer(context, filters)
end
def global_filter_names(environment = Environment.default)
Deprecations.warn("StrainerFactory.global_filter_names", "Environment#filter_method_names")
Environment.strainer_template.filter_method_names
end
end
end
+5 -1
View File
@@ -48,13 +48,17 @@ module Liquid
end
def invoke(method, *args)
if self.class.invokable?(method)
result = if self.class.invokable?(method)
send(method, *args)
elsif @context.strict_filters
raise Liquid::UndefinedFilter, "undefined filter #{method}"
else
args.first
end
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
recorder&.emit_filter_call(method, args.first, args.drop(1), result)
result
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message, e.backtrace
end
+6 -2
View File
@@ -68,8 +68,12 @@ module Liquid
private
def parse_expression(markup)
parse_context.parse_expression(markup)
def safe_parse_expression(parser)
parse_context.safe_parse_expression(parser)
end
def parse_expression(markup, safe: false)
parse_context.parse_expression(markup, safe: safe)
end
end
end
+2
View File
@@ -19,6 +19,7 @@ require_relative "tags/comment"
require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
require_relative "tags/doc"
module Liquid
module Tags
@@ -42,6 +43,7 @@ module Liquid
'if' => If,
'echo' => Echo,
'tablerow' => TableRow,
'doc' => Doc,
}.freeze
end
end
+29
View File
@@ -9,11 +9,17 @@ module Liquid
# Creates a new variable.
# @liquid_description
# You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_syntax
# {% assign variable_name = value %}
# @liquid_syntax_keyword variable_name The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Assign < Tag
include ParserSwitching
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
# @api private
@@ -25,6 +31,10 @@ 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)
@@ -33,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
+22
View File
@@ -9,6 +9,10 @@ module Liquid
# Creates a new variable with a string value.
# @liquid_description
# You can create complex strings with Liquid logic and variables.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_syntax
# {% capture variable %}
# value
@@ -16,10 +20,18 @@ module Liquid
# @liquid_syntax_keyword variable The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Capture < Block
include ParserSwitching
Syntax = /(#{VariableSignature}+)/o
attr_reader :to
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
else
@@ -27,6 +39,16 @@ 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)
+42 -6
View File
@@ -31,12 +31,7 @@ module Liquid
def initialize(tag_name, markup, options)
super
@blocks = []
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
parse_with_selected_parser(markup)
end
def parse(tokens)
@@ -91,9 +86,50 @@ module Liquid
private
def strict2_parse(markup)
parser = @parse_context.new_parser(markup)
@left = safe_parse_expression(parser)
parser.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
end
def record_when_condition(markup)
body = new_body
if strict2_mode?
parse_strict2_when(markup, body)
else
parse_lax_when(markup, body)
end
end
def parse_strict2_when(markup, body)
parser = @parse_context.new_parser(markup)
loop do
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
block = Condition.new(@left, '==', expr)
block.attach(body)
@blocks << block
break unless parser.id?('or') || parser.consume?(:comma)
end
parser.consume(:end_of_string)
end
def parse_lax_when(markup, body)
while markup
unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
+72 -13
View File
@@ -17,23 +17,13 @@ module Liquid
class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
attr_reader :variables
def initialize(tag_name, markup, options)
super
case markup
when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
@is_named = !@name.match?(/\w+:0x\h{8}/)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
parse_with_selected_parser(markup)
end
def named?
@@ -65,13 +55,82 @@ module Liquid
private
# cycle [name:] expression(, expression)*
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variables = []
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
first_expression = safe_parse_expression(p)
if p.look(:colon)
# cycle name: expr1, expr2, ...
@name = first_expression
@is_named = true
p.consume(:colon)
# After the colon, parse the first variable (required for named cycles)
@variables << maybe_dup_lookup(safe_parse_expression(p))
else
# cycle expr1, expr2, ...
@variables << maybe_dup_lookup(first_expression)
end
# Parse remaining comma-separated expressions
while p.consume?(:comma)
break if p.look(:end_of_string)
@variables << maybe_dup_lookup(safe_parse_expression(p))
end
p.consume(:end_of_string)
unless @is_named
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
end
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
case markup
when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end
def variables_from_string(markup)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil
next unless Regexp.last_match(1)
var = parse_expression(Regexp.last_match(1))
maybe_dup_lookup(var)
end.compact
end
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
# we make it so that the @variables.to_s produces different strings for cycles
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
# This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value.
# This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter.
# I was really scratching my head about this one, but migrating away from this would be more headache
# than it's worth. So we're keeping this quirk for now.
def maybe_dup_lookup(var)
var.is_a?(VariableLookup) ? var.dup : var
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
Array(@node.variables)
+21 -1
View File
@@ -7,10 +7,14 @@ module Liquid
# @liquid_name decrement
# @liquid_summary
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_description
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
# [snippets](/themes/architecture#snippets) included in the file.
# [snippets](/themes/architecture/snippets) included in the file.
#
# Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share
@@ -19,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
+81
View File
@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category syntax
# @liquid_name doc
# @liquid_summary
# Documents template elements with annotations.
# @liquid_description
# The `doc` tag allows developers to include documentation within Liquid
# templates. Any content inside `doc` tags is not rendered or outputted.
# Liquid code inside will be parsed but not executed. This facilitates
# tooling support for features like code completion, linting, and inline
# documentation.
#
# For detailed documentation syntax and examples, see the
# [`LiquidDoc` reference](/docs/storefronts/themes/tools/liquid-doc).
#
# @liquid_syntax
# {% doc %}
# Renders a message.
#
# @param {string} foo - A string value.
# @param {string} [bar] - An optional string value.
#
# @example
# {% render 'message', foo: 'Hello', bar: 'World' %}
# {% enddoc %}
class Doc < Block
NO_UNEXPECTED_ARGS = /\A\s*\z/
def initialize(tag_name, markup, parse_context)
super
ensure_valid_markup(tag_name, markup, parse_context)
end
def parse(tokens)
@body = +""
while (token = tokens.shift)
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
raise_nested_doc_error if tag_name == @tag_name
if tag_name == block_delimiter
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return
end
@body << token unless token.empty?
end
raise_tag_never_closed(block_name)
end
def render_to_output_buffer(_context, output)
output
end
def blank?
@body.empty?
end
def nodelist
[@body]
end
private
def ensure_valid_markup(tag_name, markup, parse_context)
unless NO_UNEXPECTED_ARGS.match?(markup)
raise SyntaxError, parse_context.locale.t("errors.syntax.block_tag_unexpected_args", tag: tag_name)
end
end
def raise_nested_doc_error
raise SyntaxError, parse_context.locale.t("errors.syntax.doc_invalid_nested")
end
end
end
+12 -8
View File
@@ -20,8 +20,8 @@ module Liquid
# @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render for each iteration.
# @liquid_optional_param limit [number] The number of iterations to perform.
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
# @liquid_optional_param limit: [number] The number of iterations to perform.
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
# @liquid_optional_param reversed [untyped] Iterate in reverse order.
class For < Block
@@ -88,12 +88,12 @@ module Liquid
end
def strict_parse(markup)
p = Parser.new(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
collection_name = p.expression
@collection_name = parse_expression(collection_name)
@collection_name = parse_expression(collection_name, safe: true)
@name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed')
@@ -104,13 +104,17 @@ module Liquid
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end
p.consume(:colon)
set_attribute(attribute, p.expression)
set_attribute(attribute, p.expression, safe: true)
end
p.consume(:end_of_string)
end
private
def strict2_parse(markup)
strict_parse(markup)
end
def collection_segment(context)
offsets = context.registers[:for] ||= {}
@@ -174,16 +178,16 @@ module Liquid
output
end
def set_attribute(key, expr)
def set_attribute(key, expr, safe: false)
case key
when 'offset'
@from = if expr == 'continue'
:continue
else
parse_expression(expr)
parse_expression(expr, safe: safe)
end
when 'limit'
@limit = parse_expression(expr)
@limit = parse_expression(expr, safe: safe)
end
end
+9 -5
View File
@@ -66,6 +66,10 @@ module Liquid
private
def strict2_parse(markup)
strict_parse(markup)
end
def push_block(tag, markup)
block = if tag == 'else'
ElseCondition.new
@@ -77,8 +81,8 @@ module Liquid
block.attach(new_body)
end
def parse_expression(markup)
Condition.parse_expression(parse_context, markup)
def parse_expression(markup, safe: false)
Condition.parse_expression(parse_context, markup, safe: safe)
end
def lax_parse(markup)
@@ -102,7 +106,7 @@ module Liquid
end
def strict_parse(markup)
p = Parser.new(markup)
p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
condition
@@ -120,9 +124,9 @@ module Liquid
end
def parse_comparison(p)
a = parse_expression(p.expression)
a = parse_expression(p.expression, safe: true)
if (op = p.consume?(:comparison))
b = parse_expression(p.expression)
b = parse_expression(p.expression, safe: true)
Condition.new(a, op, b)
else
Condition.new(a)
+55 -20
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category theme
# @liquid_name include
# @liquid_summary
# Renders a [snippet](/themes/architecture#snippets).
# Renders a [snippet](/themes/architecture/snippets).
# @liquid_description
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
# snippet.
@@ -20,31 +20,15 @@ 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
def initialize(tag_name, markup, options)
super
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
variable_name = Regexp.last_match(3)
@alias_name = Regexp.last_match(5)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.include")
end
parse_with_selected_parser(markup)
end
def parse(_tokens)
@@ -101,6 +85,57 @@ 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)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = safe_parse_expression(p) if with_or_for
@alias_name = p.consume(:id) if p.id?("as")
@is_for_loop = (with_or_for == FOR)
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.include")
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+21 -1
View File
@@ -7,10 +7,14 @@ module Liquid
# @liquid_name increment
# @liquid_summary
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
#
# > Caution:
# > Predefined Liquid objects can be overridden by variables with the same name.
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
# @liquid_description
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
# [snippets](/themes/architecture#snippets) included in the file.
# [snippets](/themes/architecture/snippets) included in the file.
#
# Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share
@@ -19,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
+51 -17
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category theme
# @liquid_name render
# @liquid_summary
# Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# @liquid_description
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
# of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
@@ -35,22 +35,7 @@ module Liquid
def initialize(tag_name, markup, options)
super
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
parse_with_selected_parser(markup)
end
def for_loop?
@@ -99,6 +84,55 @@ module Liquid
output
end
# render (string) (with|for expression)? (as id)? (key: value)*
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = parse_expression(strict2_template_name(p), safe: true)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = safe_parse_expression(p) if with_or_for
@alias_name = p.consume(:id) if p.id?("as")
@is_for_loop = (with_or_for == FOR)
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict2_template_name(p)
p.consume(:string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+40 -3
View File
@@ -19,17 +19,54 @@ module Liquid
# @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render.
# @liquid_optional_param cols [number] The number of columns that the table should have.
# @liquid_optional_param limit [number] The number of iterations to perform.
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
# @liquid_optional_param cols: [number] The number of columns that the table should have.
# @liquid_optional_param limit: [number] The number of iterations to perform.
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
attr_reader :variable_name, :collection_name, :attributes
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
unless p.id?("in")
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in")
end
@collection_name = safe_parse_expression(p)
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
unless ALLOWED_ATTRIBUTES.include?(key)
raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key)
end
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2))
+42 -6
View File
@@ -2,7 +2,7 @@
module Liquid
# Templates are central to liquid.
# Interpretating templates is a two step process. First you compile the
# Interpreting templates is a two step process. First you compile the
# source code you got. During compile time some extensive error checking is performed.
# your code should expect to get some SyntaxErrors.
#
@@ -24,7 +24,8 @@ module Liquid
# Sets how strict the parser should be.
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict will enforce correct syntax.
# :strict enforces correct syntax for most tags
# :strict2 enforces correct syntax for all tags
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
@@ -104,6 +105,7 @@ module Liquid
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
@root = Document.parse(tokenizer, parse_context)
@template_recorder_source = source.dup.freeze if defined?(TemplateRecorder) && TemplateRecorder.current
self
end
@@ -140,6 +142,8 @@ module Liquid
def render(*args)
return '' if @root.nil?
recording_session = TemplateRecorder.current if defined?(TemplateRecorder)
recording_assigns = args.first
context = case args.first
when Liquid::Context
c = args.shift
@@ -150,8 +154,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
@@ -177,6 +183,13 @@ module Liquid
context.add_filters(args.pop)
end
recording = recording_session&.begin_render(self, recording_assigns, context)
if recording
recorder_registers = context.registers.static
previous_recorder = recorder_registers[TemplateRecorder::REGISTER_KEY]
recorder_registers[TemplateRecorder::REGISTER_KEY] = recording
end
# Retrying a render resets resource usage
context.resource_limits.reset
@@ -186,12 +199,34 @@ module Liquid
context.template_name ||= name
previous_error_mode = context.registers.static[:template_error_mode]
context.registers.static[:template_error_mode] = @error_mode
rendered_output = nil
render_succeeded = false
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
rendered_output = @root.render_to_output_buffer(context, output || +'')
render_succeeded = true
rendered_output
rescue Liquid::MemoryError => e
context.handle_error(e)
rendered_output = context.handle_error(e)
render_succeeded = true
rendered_output
ensure
if previous_error_mode
context.registers.static[:template_error_mode] = previous_error_mode
else
context.registers.static.delete(:template_error_mode)
end
if recording
if previous_recorder
recorder_registers[TemplateRecorder::REGISTER_KEY] = previous_recorder
else
recorder_registers.delete(TemplateRecorder::REGISTER_KEY)
end
recording_session.finish_render(recording, rendered_output, context, success: render_succeeded)
end
@errors = context.errors
end
end
@@ -223,6 +258,7 @@ module Liquid
end
@warnings = parse_context.warnings
@error_mode = parse_context.error_mode
parse_context
end
+550
View File
@@ -0,0 +1,550 @@
# frozen_string_literal: true
require 'English'
require "digest/sha2"
require "json"
require "securerandom"
require "tempfile"
require "time"
module Liquid
# Records complete Liquid renders without changing the objects being rendered.
# A .json file contains one session; a .jsonl file is an append-only sequence
# of independently replayable renders.
class TemplateRecorder
FORMAT = "liquid-render"
SCHEMA_VERSION = 1
REGISTER_KEY = :__liquid_template_recorder
REPLAYER_REGISTER_KEY = :__liquid_template_recorder_replayer
class Error < StandardError; end
class ReplayError < Error; end
class SerializationError < Error; end
class << self
def record(destination, on_error: nil)
raise ArgumentError, "a block is required" unless block_given?
previous_session = current
raise Error, "nested recording sessions are not supported" if previous_session
session = Session.new(destination, on_error: on_error)
Thread.current[thread_key] = session
yield
ensure
if session
Thread.current[thread_key] = previous_session
session.close if $ERROR_INFO.nil?
end
end
def current
Thread.current[thread_key]
end
def replay_from(path, mode: :compute, index: -1)
records = Store.read(path)
raise ReplayError, "recording contains no renders" if records.empty?
record = records.fetch(index)
Replayer.new(record, mode: mode)
rescue IndexError
raise ReplayError, "render index #{index} does not exist"
end
def records(path)
Store.read(path)
end
private
def thread_key
:__liquid_template_recorder_session
end
end
class Session
def initialize(destination, on_error:)
@path = destination.to_s if destination.is_a?(String) || destination.respond_to?(:to_path)
@writer = destination unless @path
@on_error = on_error
@records = []
@active = nil
@pending_files = {}
end
def begin_render(template, _assigns, context)
if @active
@active.add_template(template)
@active.nesting += 1
return @active
end
@active = Render.new(template, context)
@pending_files.each { |path, source| @active.emit_file_read(path, source) }
@pending_files.clear
@active
end
def emit_file_read(path, source)
if @active
@active.emit_file_read(path, source)
else
@pending_files[path.to_s] = source
end
end
def emit_variable_output(output)
@active&.emit_variable_output(output)
end
def begin_tag_render(node, context)
@active&.begin_tag_render(node, context)
end
def finish_tag_render(call, output)
@active&.finish_tag_render(call, output)
end
def finish_render(render, output, context, success:)
return unless render.equal?(@active)
if render.nesting.positive?
render.nesting -= 1
return
end
if success
begin
record = render.finish(output, context)
if @writer
@writer.write(record)
elsif Store.jsonl?(@path)
Store.append(@path, record)
else
@records << record
end
rescue => error
handle_error(error)
end
end
@active = nil
end
def close
return if @writer || Store.jsonl?(@path)
Store.write_session(@path, @records)
rescue => error
handle_error(error)
end
private
def handle_error(error)
raise error unless @on_error
@on_error.call(error)
end
end
class Render
attr_accessor :nesting
def initialize(template, _context)
@nesting = 0
@templates = []
@files = {}
@filter_calls = []
@tag_calls = []
@tag_render_depth = 0
@variables = {}
@variable_outputs = []
@drop_values = {}
@bindings = {}.compare_by_identity
@root_template = template
add_template(template)
end
def add_template(template)
source = template.instance_variable_get(:@template_recorder_source)
return unless source
entrypoint = template.name
digest = Digest::SHA256.hexdigest(source)
return if @templates.any? { |item| item["sha256"] == digest && item["entrypoint"] == entrypoint }
@templates << { "source" => source, "entrypoint" => entrypoint, "sha256" => digest }
end
def emit_variable_output(output)
return if @tag_render_depth.positive?
@variable_outputs << output
end
def begin_tag_render(node, context)
name = context.environment.tags.key(node.class)
return unless name
@tag_render_depth += 1
return :nested if @tag_render_depth > 1
call = { "name" => name.to_s, "output" => nil }
@tag_calls << call
call
end
def finish_tag_render(call, output)
return unless call
@tag_render_depth -= 1
call["output"] = output unless call == :nested
end
def emit_variable_read(name, value)
path = [name.to_s]
@variables[name.to_s] = serialize(value, path, bind: true)
rescue SerializationError
# Unsupported values must not affect the render being observed.
end
def emit_drop_read(drop, key, value)
base = @bindings[drop]
return unless base
path = base + [key.to_s]
set_path(@drop_values, path, serialize(value, path, bind: true))
rescue SerializationError
# Unsupported values must not affect the render being observed.
end
def emit_file_read(path, source)
@files[path.to_s] = source.to_s
end
def emit_filter_call(name, input, arguments, output)
return if @tag_render_depth.positive?
@filter_calls << {
"name" => name.to_s,
"input" => serialize(input, ["filters", @filter_calls.length, "input"]),
"arguments" => serialize(arguments, ["filters", @filter_calls.length, "arguments"]),
"output" => serialize(output, ["filters", @filter_calls.length, "output"]),
}
rescue SerializationError
# Filter diagnostics must never make an otherwise replayable render fail.
end
def finish(output, context)
variables = deep_merge(@variables, @drop_values)
source = @root_template.instance_variable_get(:@template_recorder_source)
raise Error, "the rendered template was parsed outside the recording block" unless source
{
"format" => FORMAT,
"schema_version" => SCHEMA_VERSION,
"id" => SecureRandom.uuid,
"recorded_at" => Time.now.utc.iso8601,
"engine" => {
"liquid_version" => Liquid::VERSION,
"ruby_version" => RUBY_VERSION,
"strict_variables" => !!context.strict_variables,
"strict_filters" => !!context.strict_filters,
},
"template" => @templates.first,
"templates" => @templates,
"assigns" => variables,
"variable_outputs" => @variable_outputs,
"file_system" => @files,
"filter_calls" => @filter_calls,
"tag_calls" => @tag_calls,
"output" => output.to_s,
}
end
private
def serialize(value, path, seen = {}.compare_by_identity, bind: false)
case value
when nil, true, false, String, Integer, Float
value
when Symbol
value.to_s
when Liquid::Drop
@bindings[value] ||= path if bind
existing = value_at(@drop_values, @bindings[value])
existing || {}
when Hash
raise SerializationError, "circular value at #{format_path(path)}" if seen.key?(value)
seen[value] = true
result = value.each_with_object({}) do |(key, child), hash|
string_key = key.to_s
hash[string_key] = serialize(child, path + [string_key], seen, bind: bind)
end
seen.delete(value)
result
when Array
raise SerializationError, "circular value at #{format_path(path)}" if seen.key?(value)
seen[value] = true
result = value.each_with_index.map { |child, index| serialize(child, path + [index], seen, bind: bind) }
seen.delete(value)
result
else
raise SerializationError, "cannot record #{value.class} at #{format_path(path)}"
end
end
def set_path(root, path, value)
return root.replace(value) if path.empty? && value.is_a?(Hash)
cursor = root
path.each_with_index do |segment, index|
last = index == path.length - 1
if segment.is_a?(Integer)
break unless cursor.is_a?(Array)
end
cursor[segment] = last ? value : (cursor[segment] ||= container_for(path[index + 1]))
cursor = cursor[segment] unless last
end
end
def value_at(root, path)
return unless path
path.reduce(root) { |value, segment| value.respond_to?(:[]) ? value[segment] : nil }
end
def container_for(segment)
segment.is_a?(Integer) ? [] : {}
end
def deep_merge(left, right)
return right unless left.is_a?(Hash) && right.is_a?(Hash)
left.merge(right) { |_key, a, b| deep_merge(a, b) }
end
def format_path(path)
path.empty? ? "<root>" : path.join(".")
end
end
class Store
class << self
def jsonl?(path)
path.end_with?(".jsonl")
end
def append(path, record)
line = JSON.generate(record) << "\n"
File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
file.flock(File::LOCK_EX)
file.write(line)
file.flush
end
end
def write_session(path, records)
payload = JSON.pretty_generate(
"format" => "liquid-recording-session",
"schema_version" => SCHEMA_VERSION,
"renders" => records,
) << "\n"
directory = File.dirname(File.expand_path(path))
Tempfile.create([".liquid-recording", ".tmp"], directory, mode: File::RDWR, perm: 0o600) do |file|
file.write(payload)
file.flush
file.fsync
File.rename(file.path, path)
end
end
def read(path)
content = File.binread(path)
records = if jsonl?(path)
read_jsonl(content)
else
parsed = JSON.parse(content)
parsed["renders"] || [parsed]
end
records.each { |record| validate!(record) }
records
rescue Errno::ENOENT
raise ReplayError, "recording file not found: #{path}"
rescue JSON::ParserError => error
raise ReplayError, "invalid recording JSON: #{error.message}"
end
def read_jsonl(content)
lines = content.lines
lines.filter_map.with_index do |line, index|
next if line.strip.empty?
JSON.parse(line)
rescue JSON::ParserError
last_truncated_line = index == lines.length - 1 && !content.end_with?("\n")
raise unless last_truncated_line
end
end
def validate!(record)
raise ReplayError, "unsupported recording format" unless record["format"] == FORMAT
raise ReplayError, "unsupported schema version #{record["schema_version"].inspect}" unless record["schema_version"] == SCHEMA_VERSION
['template', 'assigns', 'file_system', 'output'].each do |key|
raise ReplayError, "recording is missing #{key}" unless record.key?(key)
end
template = record["template"]
expected = Digest::SHA256.hexdigest(template.fetch("source"))
raise ReplayError, "template checksum does not match" unless template["sha256"] == expected
rescue KeyError, TypeError => error
raise ReplayError, "invalid recording schema: #{error.message}"
end
end
end
class MemoryFileSystem
def initialize(files)
@files = files
end
def read_template_file(path)
@files.fetch(path.to_s) { raise FileSystemError, "No such template '#{path}'" }
end
end
class Replayer
def initialize(record, mode: :compute, environment: nil)
@record = record
@mode = mode.to_sym
@environment = environment
unless [:compute, :strict, :verify].include?(@mode)
raise ReplayError, "mode must be :compute, :strict, or :verify"
end
end
def render(to: nil, filters: nil)
@filter_index = 0
@tag_index = 0
@variable_index = 0
parse_options = {}
parse_options[:environment] = strict_environment if @mode == :strict
template = Liquid::Template.parse(@record.dig("template", "source"), parse_options)
registers = { file_system: MemoryFileSystem.new(@record["file_system"]) }
registers[REPLAYER_REGISTER_KEY] = self if @mode == :strict && @record.key?("variable_outputs")
options = {
registers: registers,
strict_variables: @record.dig("engine", "strict_variables"),
strict_filters: @record.dig("engine", "strict_filters"),
}
options[:filters] = filters if filters
output = template.render!(@record["assigns"], options)
if @mode == :strict
verify_filter_count! unless @record.key?("variable_outputs")
verify_tag_count!
verify_variable_count!
end
if [:strict, :verify].include?(@mode) && output != @record["output"]
raise ReplayError, "replayed output does not match the recording"
end
File.binwrite(to, output) if to
output
end
def replay_filter(name)
call = @record["filter_calls"].fetch(@filter_index) do
raise ReplayError, "unexpected filter call #{name}"
end
if call["name"] != name.to_s
raise ReplayError, "expected filter #{call["name"]}, got #{name}"
end
@filter_index += 1
JSON.parse(JSON.generate(call["output"]))
end
def replay_variable
value = @record.fetch("variable_outputs").fetch(@variable_index) do
raise ReplayError, "unexpected variable render"
end
@variable_index += 1
value
end
def replay_tag(name)
call = @record.fetch("tag_calls", []).fetch(@tag_index) do
raise ReplayError, "unexpected tag call #{name}"
end
if call["name"] != name.to_s
raise ReplayError, "expected tag #{call["name"]}, got #{name}"
end
@tag_index += 1
call["output"]
end
def recorded_output
@record["output"]
end
def templates
@record["templates"]
end
private
def strict_environment
replayer = self
strainer = Class.new(Liquid::StrainerTemplate) do
define_method(:invoke) do |name, *_args|
replayer.replay_filter(name)
end
end
tags = (@environment || Liquid::Environment.default).tags.dup
tags&.each do |name, tag_class|
tags[name] = replay_tag_class(tag_class, name)
end
Liquid::Environment.build(tags: tags) do |environment|
environment.strainer_template = strainer
end
end
def replay_tag_class(tag_class, name)
replayer = self
Class.new(tag_class) do
define_method(:render_to_output_buffer) do |_context, output|
output << replayer.replay_tag(name)
end
end
end
def verify_variable_count!
return unless @record.key?("variable_outputs")
expected = @record["variable_outputs"].length
return if @variable_index == expected
raise ReplayError, "expected #{expected} variable renders, got #{@variable_index}"
end
def verify_tag_count!
expected = @record.fetch("tag_calls", []).length
return if @tag_index == expected
raise ReplayError, "expected #{expected} tag calls, got #{@tag_index}"
end
def verify_filter_count!
expected = @record["filter_calls"].length
return if @filter_index == expected
raise ReplayError, "expected #{expected} filter calls, got #{@filter_index}"
end
end
end
end
+132 -16
View File
@@ -1,20 +1,43 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Tokenizer
attr_reader :line_number, :for_liquid_tag
def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false)
@source = source
@line_number = line_number || (line_numbers ? 1 : nil)
TAG_END = /%\}/
TAG_OR_VARIABLE_START = /\{[\{\%]/
NEWLINE = /\n/
OPEN_CURLEY = "{".ord
CLOSE_CURLEY = "}".ord
PERCENTAGE = "%".ord
def initialize(
source:,
string_scanner:,
line_numbers: false,
line_number: nil,
for_liquid_tag: false
)
@line_number = line_number || (line_numbers ? 1 : nil)
@for_liquid_tag = for_liquid_tag
@offset = 0
@tokens = tokenize
@source = source.to_s.to_str
@offset = 0
@tokens = []
if @source
@ss = string_scanner
@ss.string = @source
tokenize
end
end
def shift
token = @tokens[@offset]
return nil unless token
return unless token
@offset += 1
@@ -28,18 +51,111 @@ module Liquid
private
def tokenize
return [] if @source.empty?
return @source.split("\n") if @for_liquid_tag
tokens = @source.split(TemplateParser)
# removes the rogue empty element at the beginning of the array
if tokens[0]&.empty?
@offset += 1
if @for_liquid_tag
@tokens = @source.split("\n")
else
@tokens << shift_normal until @ss.eos?
end
tokens
@source = nil
@ss = nil
end
def shift_normal
token = next_token
return unless token
token
end
def next_token
# possible states: :text, :tag, :variable
byte_a = @ss.peek_byte
if byte_a == OPEN_CURLEY
@ss.scan_byte
byte_b = @ss.peek_byte
if byte_b == PERCENTAGE
@ss.scan_byte
return next_tag_token
elsif byte_b == OPEN_CURLEY
@ss.scan_byte
return next_variable_token
end
@ss.pos -= 1
end
next_text_token
end
def next_text_token
start = @ss.pos
unless @ss.skip_until(TAG_OR_VARIABLE_START)
token = @ss.rest
@ss.terminate
return token
end
pos = @ss.pos -= 2
@source.byteslice(start, pos - start)
rescue ::ArgumentError => e
if e.message == "invalid byte sequence in #{@ss.string.encoding}"
raise SyntaxError, "Invalid byte sequence in #{@ss.string.encoding}"
else
raise
end
end
def next_variable_token
start = @ss.pos - 2
byte_a = byte_b = @ss.scan_byte
while byte_b
byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY
break unless byte_a
if @ss.eos?
return byte_a == CLOSE_CURLEY ? @source.byteslice(start, @ss.pos - start) : "{{"
end
byte_b = @ss.scan_byte
if byte_a == CLOSE_CURLEY
if byte_b == CLOSE_CURLEY
return @source.byteslice(start, @ss.pos - start)
elsif byte_b != CLOSE_CURLEY
@ss.pos -= 1
return @source.byteslice(start, @ss.pos - start)
end
elsif byte_a == OPEN_CURLEY && byte_b == PERCENTAGE
return next_tag_token_with_start(start)
end
byte_a = byte_b
end
"{{"
end
def next_tag_token
start = @ss.pos - 2
if (len = @ss.skip_until(TAG_END))
@source.byteslice(start, len + 2)
else
"{%"
end
end
def next_tag_token_with_start(start)
@ss.skip_until(TAG_END)
@source.byteslice(start, @ss.pos - start)
end
end
end
+104 -3
View File
@@ -2,6 +2,9 @@
module Liquid
module Utils
DECIMAL_REGEX = /\A-?\d+\.\d+\z/
UNIX_TIMESTAMP_REGEX = /\A\d+\z/
def self.slice_collection(collection, from, to)
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
collection.load_slice(from, to)
@@ -52,7 +55,7 @@ module Liquid
when Numeric
obj
when String
/\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
else
if obj.respond_to?(:to_number)
obj.to_number
@@ -66,14 +69,14 @@ 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
case obj
when 'now', 'today'
Time.now
when /\A\d+\z/, Integer
when UNIX_TIMESTAMP_REGEX, Integer
Time.at(obj.to_i)
when String
Time.parse(obj)
@@ -89,5 +92,103 @@ module Liquid
# Otherwise return the object itself
obj
end
def self.to_s(obj, seen = {})
case obj
when BigDecimal
obj.to_s("F")
when Hash
# If the custom hash implementation overrides `#to_s`, use their
# custom implementation. Otherwise we use Liquid's default
# implementation.
if obj.class.instance_method(:to_s) == HASH_TO_S_METHOD
hash_inspect(obj, seen)
else
obj.to_s
end
when Array
array_inspect(obj, seen)
else
obj.to_s
end
end
def self.inspect(obj, seen = {})
case obj
when Hash
# If the custom hash implementation overrides `#inspect`, use their
# custom implementation. Otherwise we use Liquid's default
# implementation.
if obj.class.instance_method(:inspect) == HASH_INSPECT_METHOD
hash_inspect(obj, seen)
else
obj.inspect
end
when Array
array_inspect(obj, seen)
else
obj.inspect
end
end
def self.array_inspect(arr, seen = {})
if seen[arr.object_id]
return "[...]"
end
seen[arr.object_id] = true
str = +"["
cursor = 0
len = arr.length
while cursor < len
if cursor > 0
str << ", "
end
item_str = inspect(arr[cursor], seen)
str << item_str
cursor += 1
end
str << "]"
str
ensure
seen.delete(arr.object_id)
end
def self.hash_inspect(hash, seen = {})
if seen[hash.object_id]
return "{...}"
end
seen[hash.object_id] = true
str = +"{"
first = true
hash.each do |key, value|
if first
first = false
else
str << ", "
end
key_str = inspect(key, seen)
str << key_str
str << "=>"
value_str = inspect(value, seen)
str << value_str
end
str << "}"
str
ensure
seen.delete(hash.object_id)
end
HASH_TO_S_METHOD = Hash.instance_method(:to_s)
private_constant :HASH_TO_S_METHOD
HASH_INSPECT_METHOD = Hash.instance_method(:inspect)
private_constant :HASH_INSPECT_METHOD
end
end
+79 -12
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
@@ -54,26 +58,37 @@ module Liquid
next unless f =~ /\w+/
filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten
@filters << parse_filter_expressions(filtername, filterargs)
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
end
end
def strict_parse(markup)
@filters = []
p = Parser.new(markup)
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.parse_expression(p.expression)
@name = parse_context.safe_parse_expression(p)
while p.consume?(:pipe)
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << parse_filter_expressions(filtername, filterargs)
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
p.consume(:end_of_string)
end
def strict2_parse(markup)
@filters = []
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
@filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
def parse_filterargs(p)
# first argument
filterargs = [p.argument]
@@ -94,18 +109,30 @@ module Liquid
end
def render_to_output_buffer(context, output)
obj = render(context)
if obj.is_a?(Array)
output << obj.join
elsif obj.nil?
else
output << obj.to_s
if (replayer = context.registers[TemplateRecorder::REPLAYER_REGISTER_KEY])
return output << replayer.replay_variable
end
output_start = output.length
obj = render(context)
render_obj_to_output(obj, output)
TemplateRecorder.current&.emit_variable_output(output[output_start..])
output
end
def render_obj_to_output(obj, output)
case obj
when NilClass
# Do nothing
when Array
obj.each do |o|
render_obj_to_output(o, output)
end
else
output << Liquid::Utils.to_s(obj)
end
end
def disabled?(_context)
false
end
@@ -116,7 +143,7 @@ module Liquid
private
def parse_filter_expressions(filter_name, unparsed_args)
def lax_parse_filter_expressions(filter_name, unparsed_args)
filter_args = []
keyword_args = nil
unparsed_args.each do |a|
@@ -132,6 +159,46 @@ module Liquid
result
end
# Surprisingly, positional and keyword arguments can be mixed.
#
# filter = filtername [":" filterargs?]
# filterargs = argument ("," argument)*
# argument = (positional_argument | keyword_argument)
# positional_argument = expression
# keyword_argument = id ":" expression
def strict2_parse_filter_expressions(p)
filtername = p.consume(:id)
filter_args = []
keyword_args = {}
if p.consume?(:colon)
# Parse first argument (no leading comma)
argument(p, filter_args, keyword_args) unless end_of_arguments?(p)
# Parse remaining arguments (with leading commas) and optional trailing comma
argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p)
end
result = [filtername, filter_args]
result << keyword_args unless keyword_args.empty?
result
end
def argument(p, positional_arguments, keyword_arguments)
if p.look(:id) && p.look(:colon, 1)
key = p.consume(:id)
p.consume(:colon)
value = parse_context.safe_parse_expression(p)
keyword_arguments[key] = value
else
positional_arguments << parse_context.safe_parse_expression(p)
end
end
def end_of_arguments?(p)
p.look(:pipe) || p.look(:end_of_string)
end
def evaluate_filter_expressions(context, filter_args, filter_kwargs)
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
if filter_kwargs
+18 -5
View File
@@ -6,16 +6,20 @@ module Liquid
attr_reader :name, :lookups
def self.parse(markup)
new(markup)
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
new(markup, string_scanner, cache)
end
def initialize(markup)
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
lookups = markup.scan(VariableParser)
name = lookups.shift
if name&.start_with?('[') && name&.end_with?(']')
name = Expression.parse(name[1..-2])
name = Expression.parse(
name[1..-2],
string_scanner,
cache,
)
end
@name = name
@@ -25,7 +29,11 @@ module Liquid
@lookups.each_index do |i|
lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']')
lookups[i] = Expression.parse(lookup[1..-2])
lookups[i] = Expression.parse(
lookup[1..-2],
string_scanner,
cache,
)
elsif COMMAND_METHODS.include?(lookup)
@command_flags |= 1 << i
end
@@ -62,6 +70,11 @@ 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.6.0.alpha"
VERSION = "5.13.0"
end
+2 -2
View File
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
s.authors = ["Tobias Lütke"]
s.email = ["[email protected]"]
s.homepage = "http://www.liquidmarkup.org"
s.homepage = "https://shopify.github.io/liquid/"
s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup."
@@ -28,7 +28,7 @@ Gem::Specification.new do |s|
s.require_path = "lib"
s.add_dependency("strscan")
s.add_dependency("strscan", ">= 3.1.1")
s.add_dependency("bigdecimal")
s.add_development_dependency('rake', '~> 13.0')
+10 -6
View File
@@ -4,18 +4,22 @@ require 'benchmark/ips'
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
Benchmark.ips do |x|
x.time = 10
x.warmup = 5
x.time = 20
x.warmup = 10
puts
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
puts
x.report("parse:") { profiler.compile }
x.report("render:") { profiler.render }
x.report("parse & render:") { profiler.run }
phase = ENV["PHASE"] || "all"
x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize"
x.report("parse:") { profiler.compile } if phase == "all" || phase == "parse"
x.report("render:") { profiler.render } if phase == "all" || phase == "render"
x.report("parse & render:") { profiler.run } if phase == "all" || phase == "run"
end
+8 -7
View File
@@ -11,11 +11,12 @@ require_relative 'shop_filter'
require_relative 'tag_filter'
require_relative 'weight_filter'
Liquid::Template.register_tag('paginate', Paginate)
Liquid::Template.register_tag('form', CommentForm)
default_environment = Liquid::Environment.default
default_environment.register_tag('paginate', Paginate)
default_environment.register_tag('form', CommentForm)
Liquid::Template.register_filter(JsonFilter)
Liquid::Template.register_filter(MoneyFilter)
Liquid::Template.register_filter(WeightFilter)
Liquid::Template.register_filter(ShopFilter)
Liquid::Template.register_filter(TagFilter)
default_environment.register_filter(JsonFilter)
default_environment.register_filter(MoneyFilter)
default_environment.register_filter(WeightFilter)
default_environment.register_filter(ShopFilter)
default_environment.register_filter(TagFilter)
+13
View File
@@ -48,6 +48,19 @@ class ThemeRunner
end
end
# `tokenize` will just test the tokenizen portion of liquid without any templates
def tokenize
ss = StringScanner.new("")
@tests.each do |test_hash|
tokenizer = Liquid::Tokenizer.new(
source: test_hash[:liquid],
string_scanner: ss,
line_numbers: true,
)
while tokenizer.shift; end
end
end
# `run` is called to benchmark rendering and compiling at the same time
def run
each_test do |liquid, layout, assigns, page_template, template_name|
+94
View File
@@ -0,0 +1,94 @@
# frozen_string_literal: true
require "benchmark/ips"
# benchmark liquid lexing
require 'liquid'
RubyVM::YJIT.enable
STRING_MARKUPS = [
"\"foo\"",
"\"fooooooooooo\"",
"\"foooooooooooooooooooooooooooooo\"",
"'foo'",
"'fooooooooooo'",
"'foooooooooooooooooooooooooooooo'",
]
VARIABLE_MARKUPS = [
"article",
"article.title",
"article.title.size",
"very_long_variable_name_2024_11_05",
"very_long_variable_name_2024_11_05.size",
]
NUMBER_MARKUPS = [
"0",
"35",
"1241891024912849",
"3.5",
"3.51214128409128",
"12381902839.123819283910283",
"123.456.789",
"-123",
"-12.33",
"-405.231",
"-0",
"0",
"0.0",
"0.0000000000000000000000",
"0.00000000001",
]
RANGE_MARKUPS = [
"(1..30)",
"(1...30)",
"(1..30..5)",
"(1.0...30.0)",
"(1.........30)",
"(1..foo)",
"(foo..30)",
"(foo..bar)",
"(foo...bar...100)",
"(foo...bar...100.0)",
]
LITERAL_MARKUPS = [
nil,
'nil',
'null',
'',
'true',
'false',
'blank',
'empty',
]
MARKUPS = {
"string" => STRING_MARKUPS,
"literal" => LITERAL_MARKUPS,
"variable" => VARIABLE_MARKUPS,
"number" => NUMBER_MARKUPS,
"range" => RANGE_MARKUPS,
}
Benchmark.ips do |x|
x.config(time: 5, warmup: 5)
MARKUPS.each do |type, markups|
x.report("Liquid::Expression#parse: #{type}") do
markups.each do |markup|
Liquid::Expression.parse(markup)
end
end
end
x.report("Liquid::Expression#parse: all") do
MARKUPS.values.flatten.each do |markup|
Liquid::Expression.parse(markup)
end
end
end
+2 -21
View File
@@ -29,31 +29,12 @@ EXPRESSIONS = [
"foo | default: -1",
]
EXPRESSIONS.each do |expr|
lexer_1_result = Liquid::Lexer1.new(expr).tokenize
lexer_2_result = Liquid::Lexer2.new(expr).tokenize
next if lexer_1_result == lexer_2_result
warn "Lexer1 and Lexer2 results are different for expression: #{expr}"
warn "expected: #{lexer_1_result}"
warn "got: #{lexer_2_result}"
abort
end
Benchmark.ips do |x|
x.config(time: 10, warmup: 5)
x.report("Liquid::Lexer1#tokenize") do
x.report("Liquid::Lexer#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer1.new(expr)
l.tokenize
end
end
x.report("Liquid::Lexer2#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer2.new(expr)
l = Liquid::Lexer.new(expr)
l.tokenize
end
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
+18 -3
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
@@ -632,13 +632,28 @@ class ContextTest < Minitest::Test
end
def test_has_key_will_not_add_an_error_for_missing_keys
with_error_mode(:strict) do
with_error_modes(:strict) do
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
end
end
def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled
context = Context.new
context.strict_variables = true
assert_raises(Liquid::UndefinedVariable) do
context['unknown']
end
end
def test_has_key_will_not_raise_for_missing_keys_when_strict_variables_is_enabled
context = Context.new
context.strict_variables = true
refute(context.key?('unknown'))
assert_empty(context.errors)
end
def test_context_always_uses_static_registers
registers = {
my_register: :my_value,
+7 -7
View File
@@ -67,7 +67,7 @@ class ErrorHandlingTest < Minitest::Test
end
def test_unrecognized_operator
with_error_mode(:strict) do
with_error_modes(:strict) do
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
@@ -203,20 +203,19 @@ class ErrorHandlingTest < Minitest::Test
end
def test_setting_default_exception_renderer
old_exception_renderer = Liquid::Template.default_exception_renderer
exceptions = []
Liquid::Template.default_exception_renderer = ->(e) {
default_exception_renderer = ->(e) {
exceptions << e
''
}
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}')
env = Liquid::Environment.build(exception_renderer: default_exception_renderer)
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: env)
output = template.render('errors' => ErrorDrop.new)
assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
ensure
Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer
end
def test_setting_exception_renderer_on_environment
@@ -270,7 +269,8 @@ class ErrorHandlingTest < Minitest::Test
assert_equal("Liquid error: comparison of Integer with String failed0", output)
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
assert_equal("0", output)
expected = ENV["LIQUID_PARSER_MODE"] == "strict2" ? "Liquid error: comparison of Integer with String failed0" : "0"
assert_equal(expected, output)
end
def test_syntax_error_is_raised_with_template_name
+141
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'test_helper'
require 'lru_redux'
class ExpressionTest < Minitest::Test
def test_keyword_literals
@@ -13,6 +14,7 @@ class ExpressionTest < Minitest::Test
assert_template_result("double quoted", '{{"double quoted"}}')
assert_template_result("spaced", "{{ 'spaced' }}")
assert_template_result("spaced2", "{{ 'spaced2' }}")
assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
end
def test_int
@@ -22,8 +24,22 @@ class ExpressionTest < Minitest::Test
end
def test_float
assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}")
with_error_modes(:lax) do
assert_expression_result(0.0, "0.....5")
assert_expression_result(0.0, "-0..1")
end
assert_expression_result(1.5, "1.5")
# this is a unfortunate quirky behavior of Liquid
result = Expression.parse(".5")
assert_kind_of(Liquid::VariableLookup, result)
result = Expression.parse("-.5")
assert_kind_of(Liquid::VariableLookup, result)
end
def test_range
@@ -40,6 +56,131 @@ class ExpressionTest < Minitest::Test
)
end
def test_quirky_negative_sign_expression_markup
result = Expression.parse("-", nil)
assert(result.is_a?(VariableLookup))
assert_equal("-", result.name)
# for this template, the expression markup is "-"
assert_template_result(
"",
"{{ - 'theme.css' - }}",
error_mode: :lax,
)
end
def test_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
cache = {}
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
def test_expression_cache_with_true_boolean
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
parse_context = ParseContext.new(expression_cache: true)
Liquid::Template.parse(template, parse_context).render
cache = parse_context.instance_variable_get(:@expression_cache)
assert_equal(
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
def test_expression_cache_with_lru_redux
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
cache = LruRedux::Cache.new(10)
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
def test_disable_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
parse_context = Liquid::ParseContext.new(expression_cache: false)
Liquid::Template.parse(template, parse_context).render
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
end
def test_safe_parse_with_variable_lookup
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('product.title')
result = Liquid::Expression.safe_parse(parser)
assert_instance_of(Liquid::VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_safe_parse_with_number
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('42')
result = Liquid::Expression.safe_parse(parser)
assert_equal(42, result)
end
def test_safe_parse_raises_syntax_error_for_invalid_expression
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('')
error = assert_raises(Liquid::SyntaxError) do
Liquid::Expression.safe_parse(parser)
end
assert_match(/is not a valid expression/, error.message)
end
private
def assert_expression_result(expect, markup, **assigns)
+100
View File
@@ -0,0 +1,100 @@
# frozen_string_literal: true
require 'test_helper'
class HashRenderingTest < Minitest::Test
def test_render_empty_hash
assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} })
end
def test_render_hash_with_string_keys_and_values
assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } })
end
def test_render_hash_with_symbol_keys_and_integer_values
assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } })
end
def test_render_nested_hash
assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } })
end
def test_render_hash_with_array_values
assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } })
end
def test_render_recursive_hash
recursive_hash = { "self" => {} }
recursive_hash["self"]["self"] = recursive_hash
assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash })
end
def test_hash_with_downcase_filter
assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_upcase_filter
assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_strip_filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_escape_filter
assert_template_result("{&quot;Key&quot;=&gt;&quot;Value&quot;, &quot;AnotherKey&quot;=&gt;&quot;AnotherValue&quot;}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_url_encode_filter
assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_strip_html_filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_truncate__20_filter
assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_replace___key____replaced_key__filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_append____appended_text__filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_prepend___prepended_text___filter
assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_render_hash_with_array_values_empty
assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } })
end
def test_render_hash_with_array_values_hash
assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } })
end
def test_join_filter_with_hash
array = [{ "key1" => "value1" }, { "key2" => "value2" }]
glue = { "lol" => "wut" }
assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue })
end
def test_render_hash_with_hash_key
assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } })
end
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
end
def test_rendering_hash_without_custom_to_s_uses_default_inspect
my_hash = HashWithoutCustomToS.new
my_hash[:foo] = :bar
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
end
end
+18 -10
View File
@@ -31,18 +31,18 @@ class ParsingQuirksTest < Minitest::Test
def test_error_on_empty_filter
assert(Template.parse("{{test}}"))
with_error_mode(:lax) do
with_error_modes(:lax) do
assert(Template.parse("{{|test}}"))
end
with_error_mode(:strict) do
with_error_modes(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end
end
def test_meaningless_parens_error
with_error_mode(:strict) do
with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
@@ -51,7 +51,7 @@ class ParsingQuirksTest < Minitest::Test
end
def test_unexpected_characters_syntax_error
with_error_mode(:strict) do
with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
@@ -70,7 +70,7 @@ class ParsingQuirksTest < Minitest::Test
end
def test_meaningless_parens_lax
with_error_mode(:lax) do
with_error_modes(:lax) do
assigns = { 'b' => 'bar', 'c' => 'baz' }
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
@@ -78,7 +78,7 @@ class ParsingQuirksTest < Minitest::Test
end
def test_unexpected_characters_silently_eat_logic_lax
with_error_mode(:lax) do
with_error_modes(:lax) do
markup = "true && false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
markup = "false || true"
@@ -93,7 +93,7 @@ class ParsingQuirksTest < Minitest::Test
end
def test_unanchored_filter_arguments
with_error_mode(:lax) do
with_error_modes(:lax) do
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
assert_template_result('x', "{{ 'X' | downcase) }}")
@@ -106,14 +106,14 @@ class ParsingQuirksTest < Minitest::Test
end
def test_invalid_variables_work
with_error_mode(:lax) do
with_error_modes(:lax) do
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
end
end
def test_extra_dots_in_ranges
with_error_mode(:lax) do
with_error_modes(:lax) do
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end
end
@@ -133,7 +133,15 @@ class ParsingQuirksTest < Minitest::Test
end
def test_incomplete_expression
with_error_mode(:lax) do
with_error_modes(:lax) do
assert_template_result("false", "{{ false - }}")
assert_template_result("false", "{{ false > }}")
assert_template_result("false", "{{ false < }}")
assert_template_result("false", "{{ false = }}")
assert_template_result("false", "{{ false ! }}")
assert_template_result("false", "{{ false 1 }}")
assert_template_result("false", "{{ false a }}")
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
+1 -1
View File
@@ -33,7 +33,7 @@ class ProfilerTest < Minitest::Test
end
def setup
Liquid::Template.file_system = ProfilingFileSystem.new
Liquid::Environment.default.file_system = ProfilingFileSystem.new
end
def test_template_allows_flagging_profiling
+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
+316 -7
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))
@@ -133,6 +133,18 @@ class StandardFiltersTest < Minitest::Test
assert_equal([], @filters.slice(input, -(1 << 63), 6))
end
def test_find_on_empty_array
assert_nil(@filters.find([], 'foo', 'bar'))
end
def test_find_index_on_empty_array
assert_nil(@filters.find_index([], 'foo', 'bar'))
end
def test_has_on_empty_array
refute(@filters.has([], 'foo', 'bar'))
end
def test_truncate
assert_equal('1234...', @filters.truncate('1234567890', 7))
assert_equal('1234567890', @filters.truncate('1234567890', 20))
@@ -152,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))
@@ -281,6 +300,10 @@ class StandardFiltersTest < Minitest::Test
assert_equal('1121314', @filters.join([1, 2, 3, 4], 1))
end
def test_join_calls_to_liquid_on_each_element
assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
end
def test_sort
assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1]))
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
@@ -538,12 +561,23 @@ class StandardFiltersTest < Minitest::Test
end
end
def test_map_returns_empty_with_no_property
def test_map_with_value_property
array = [
{ "handle" => "alpha", "value" => "A" },
{ "handle" => "beta", "value" => "B" },
{ "handle" => "gamma", "value" => "C" }
]
assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array })
end
def test_map_returns_input_with_no_property
foo = [
[1],
[2],
[3],
]
assert_raises(Liquid::ArgumentError) do
@filters.map(foo, nil)
end
@@ -600,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))
@@ -827,21 +895,233 @@ class StandardFiltersTest < Minitest::Test
assert_template_result('abc', "{{ 'abc' | date: '%D' }}")
end
def test_where
input = [
def test_reject
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
expectation = [
template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}"
expected_output = "beta gamma"
assert_template_result(expected_output, template, { "array" => array })
end
def test_reject_with_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
assert_equal(expectation, @filters.where(input, "ok", true))
assert_equal(expectation, @filters.where(input, "ok"))
template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}"
expected_output = "beta gamma"
assert_template_result(expected_output, template, { "array" => array })
end
def test_reject_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}"
expected_output = "alpha delta"
assert_template_result(expected_output, template, { "array" => array })
end
def test_has
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => false },
]
expected_output = "true"
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
end
def test_has_when_does_not_have_it
array = [
{ "handle" => "alpha", "ok" => false },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => false },
]
expected_output = "false"
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
end
def test_has_with_empty_arrays
template = <<~LIQUID
{%- assign has_product = products | has: 'title.content', 'Not found' -%}
{%- unless has_product -%}
Product not found.
{%- endunless -%}
LIQUID
expected_output = "Product not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_has_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | has: 'ok', false }}"
expected_output = "true"
assert_template_result(expected_output, template, { "array" => array })
end
def test_has_with_false_value_when_does_not_have_it
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => true },
{ "handle" => "gamma", "ok" => true },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | has: 'ok', false }}"
expected_output = "false"
assert_template_result(expected_output, template, { "array" => array })
end
def test_find_with_value
products = [
{ "title" => "Pro goggles", "price" => 1299 },
{ "title" => "Thermal gloves", "price" => 1499 },
{ "title" => "Alpine jacket", "price" => 3999 },
{ "title" => "Mountain boots", "price" => 3899 },
{ "title" => "Safety helmet", "price" => 1999 }
]
template = <<~LIQUID
{%- assign product = products | find: 'price', 3999 -%}
{{- product.title -}}
LIQUID
expected_output = "Alpine jacket"
assert_template_result(expected_output, template, { "products" => products })
end
def test_find_with_empty_arrays
template = <<~LIQUID
{%- assign product = products | find: 'title.content', 'Not found' -%}
{%- unless product -%}
Product not found.
{%- endunless -%}
LIQUID
expected_output = "Product not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_find_index_with_value
products = [
{ "title" => "Pro goggles", "price" => 1299 },
{ "title" => "Thermal gloves", "price" => 1499 },
{ "title" => "Alpine jacket", "price" => 3999 },
{ "title" => "Mountain boots", "price" => 3899 },
{ "title" => "Safety helmet", "price" => 1999 }
]
template = <<~LIQUID
{%- assign index = products | find_index: 'price', 3999 -%}
{{- index -}}
LIQUID
expected_output = "2"
assert_template_result(expected_output, template, { "products" => products })
end
def test_find_index_with_empty_arrays
template = <<~LIQUID
{%- assign index = products | find_index: 'title.content', 'Not found' -%}
{%- unless index -%}
Index not found.
{%- endunless -%}
LIQUID
expected_output = "Index not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_where
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}"
expected_output = "alpha delta"
assert_template_result(expected_output, template, { "array" => array })
end
def test_where_with_empty_string_is_a_no_op
environment = { "array" => ["alpha", "beta", "gamma"] }
expected_output = "alpha beta gamma"
template = "{{ array | where: '' | join: ' ' }}"
assert_template_result(expected_output, template, environment)
end
def test_where_with_nil_is_a_no_op
environment = { "array" => ["alpha", "beta", "gamma"] }
template = "{{ array | where: nil | join: ' ' }}"
assert_raises(Liquid::ArgumentError) do
assert_template_result("alpha beta gamma", template, environment)
end
end
def test_where_with_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}"
expected_output = "alpha delta"
assert_template_result(expected_output, template, { "array" => array })
end
def test_where_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}"
expected_output = "beta gamma"
assert_template_result(expected_output, template, { "array" => array })
end
def test_where_string_keys
@@ -901,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
@@ -1051,6 +1333,33 @@ class StandardFiltersTest < Minitest::Test
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
end
def test_sum_with_non_string_property
input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }]
assert_equal(1, @filters.sum(input, true))
assert_equal(0.2, @filters.sum(input, 1.0))
assert_equal(-0.3, @filters.sum(input, 1))
assert_equal(0.4, @filters.sum(input, 1..5))
assert_equal(0, @filters.sum(input, nil))
assert_equal(0, @filters.sum(input, ""))
end
def test_uniq_with_to_liquid_value
input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"]
expected = [StringDrop.new("foo"), StringDrop.new("bar")]
result = @filters.uniq(input)
assert_equal(expected, result)
end
def test_uniq_with_to_liquid_value_pick_correct_classes
input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")]
expected = [String, StringDrop]
result = @filters.uniq(input).map(&:class)
assert_equal(expected, result)
end
private
def with_timezone(tz)
+180
View File
@@ -0,0 +1,180 @@
# frozen_string_literal: true
require 'test_helper'
class CycleTagTest < Minitest::Test
def test_simple_cycle_inside_for_loop
template = <<~LIQUID
{%- for i in (1..3) -%}
{%- cycle '1', '2', '3' -%}
{%- endfor -%}
LIQUID
assert_template_result("123", template)
end
def test_cycle_with_variables_inside_for_loop
template = <<~LIQUID
{%- assign a = 1 -%}
{%- assign b = 2 -%}
{%- assign c = 3 -%}
{%- for i in (1..3) -%}
{% cycle a, b, c %}
{%- endfor -%}
LIQUID
assert_template_result("123", template)
end
def test_cycle_named_groups_string
template = <<~LIQUID
{%- for i in (1..3) -%}
{%- cycle 'placeholder1': 1, 2, 3 -%}
{%- cycle 'placeholder2': 1, 2, 3 -%}
{%- endfor -%}
LIQUID
assert_template_result("112233", template)
end
def test_cycle_named_groups_vlookup
template = <<~LIQUID
{%- assign placeholder1 = 'placeholder1' -%}
{%- assign placeholder2 = 'placeholder2' -%}
{%- for i in (1..3) -%}
{%- cycle placeholder1: 1, 2, 3 -%}
{%- cycle placeholder2: 1, 2, 3 -%}
{%- endfor -%}
LIQUID
assert_template_result("112233", template)
end
def test_unnamed_cycle_have_independent_counters_when_used_with_lookups
template = <<~LIQUID
{%- assign a = "1" -%}
{%- for i in (1..3) -%}
{%- cycle a, "2" -%}
{%- cycle a, "2" -%}
{%- endfor -%}
LIQUID
assert_template_result("112211", template)
end
def test_unnamed_cycle_dependent_counter_when_used_with_literal_values
template = <<~LIQUID
{%- cycle "1", "2" -%}
{%- cycle "1", "2" -%}
{%- cycle "1", "2" -%}
LIQUID
assert_template_result("121", template)
end
def test_optional_trailing_comma
template = <<~LIQUID
{%- cycle "1", "2", -%}
{%- cycle "1", "2", -%}
{%- cycle "1", "2", -%}
{%- cycle "1", -%}
LIQUID
assert_template_result("1211", template)
end
def test_cycle_tag_without_arguments
error = assert_raises(Liquid::SyntaxError) do
Template.parse("{% cycle %}")
end
assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
end
def test_cycle_tag_with_error_mode
# QuotedFragment is more permissive than what Parser#expression allows.
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
template2 = "{% cycle .5: 'a', 'b' %}"
with_error_modes(:lax, :strict) do
assert_template_result("b", template1)
assert_template_result("a", template2)
end
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
assert_match(/Liquid syntax error:/, error1.message)
assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message)
end
end
def test_cycle_with_trailing_elements
assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}"
template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}"
template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}"
template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}"
with_error_modes(:lax, :strict) do
assert_template_result("a", template1)
assert_template_result("a", template2)
assert_template_result("a", template3)
assert_template_result("N", template4)
assert_template_result("N", template5)
end
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
expected_error = /Expected end_of_string but found/
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
assert_match(expected_error, error3.message)
assert_match(expected_error, error4.message)
assert_match(expected_error, error5.message)
end
end
def test_cycle_name_with_invalid_expression
template = <<~LIQUID
{% for i in (1..3) %}
{% cycle foo=>bar: "a", "b" %}
{% endfor %}
LIQUID
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_cycle_variable_with_invalid_expression
template = <<~LIQUID
{% for i in (1..3) %}
{% cycle foo=>bar, "a", "b" %}
{% endfor %}
LIQUID
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+127 -13
View File
@@ -174,10 +174,10 @@ class IncludeTagTest < Minitest::Test
end
end
Liquid::Template.file_system = infinite_file_system.new
env = Liquid::Environment.build(file_system: infinite_file_system.new)
assert_raises(Liquid::StackLevelError) do
Template.parse("{% include 'loop' %}").render!
Template.parse("{% include 'loop' %}", environment: env).render!
end
end
@@ -204,6 +204,32 @@ class IncludeTagTest < Minitest::Test
)
end
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
)
end
with_error_modes(:strict2) do
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% include "snippet" | filter %}',
)
end
end
def test_optional_commas
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
end
def test_include_tag_caches_second_read_of_same_partial
file_system = CountingFileSystem.new
environment = Liquid::Environment.build(file_system: file_system)
@@ -264,26 +290,27 @@ class IncludeTagTest < Minitest::Test
end
def test_does_not_add_error_in_strict_mode_for_missing_variable
Liquid::Template.file_system = TestFileSystem.new
env = Liquid::Environment.build(file_system: TestFileSystem.new)
a = Liquid::Template.parse(' {% include "nested_template" %}')
a = Liquid::Template.parse(' {% include "nested_template" %}', environment: env)
a.render!
assert_empty(a.errors)
end
def test_passing_options_to_included_templates
Liquid::Template.file_system = TestFileSystem.new
env = Liquid::Environment.build(file_system: TestFileSystem.new)
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}')
Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true).render!("template" => '{{ "X" || downcase }}'))
with_error_modes(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
end
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale]).render!("template" => '{{ "X" || downcase }}')
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode]).render!("template" => '{{ "X" || downcase }}'))
with_error_modes(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
end
end
@@ -334,8 +361,11 @@ class IncludeTagTest < Minitest::Test
end
def test_including_with_strict_variables
Liquid::Template.file_system = StubFileSystem.new({ "simple" => "simple" })
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn)
env = Liquid::Environment.build(
file_system: StubFileSystem.new('simple' => 'simple'),
)
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template.render(nil, strict_variables: true)
assert_equal([], template.errors)
@@ -370,4 +400,88 @@ class IncludeTagTest < Minitest::Test
render_errors: true,
)
end
def test_include_template_with_invalid_expression
template = "{% include foo=>bar %}"
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_with_invalid_expression
template = '{% include "snippet" with foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_attribute_with_invalid_expression
template = '{% include "snippet", key: foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_for_loop_true_with_for_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' for products %}")
include_node = template.root.nodelist.first
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' keyword")
end
end
def test_include_for_loop_false_with_with_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' with product %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' keyword")
end
end
def test_include_for_loop_false_without_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'header' %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false when no keyword")
end
end
def test_include_for_loop_with_alias
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' for products as item %}")
include_node = template.root.nodelist.first
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' with alias")
end
end
def test_include_with_keyword_and_alias
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' with products[0] as item %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' with alias")
end
end
end # IncludeTagTest
@@ -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
+1
View File
@@ -16,6 +16,7 @@ class RawTagTest < Minitest::Test
assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <')
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <")
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <")
assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}")
end
def test_open_tag_in_raw
+65 -10
View File
@@ -82,19 +82,22 @@ class RenderTagTest < Minitest::Test
end
def test_recursively_rendered_template_does_not_produce_endless_loop
Liquid::Template.file_system = StubFileSystem.new('loop' => '{% render "loop" %}')
env = Liquid::Environment.build(
file_system: StubFileSystem.new('loop' => '{% render "loop" %}'),
)
assert_raises(Liquid::StackLevelError) do
Template.parse('{% render "loop" %}').render!
Template.parse('{% render "loop" %}', environment: env).render!
end
end
def test_sub_contexts_count_towards_the_same_recursion_limit
Liquid::Template.file_system = StubFileSystem.new(
'loop_render' => '{% render "loop_render" %}',
env = Liquid::Environment.build(
file_system: StubFileSystem.new('loop_render' => '{% render "loop_render" %}'),
)
assert_raises(Liquid::StackLevelError) do
Template.parse('{% render "loop_render" %}').render!
Template.parse('{% render "loop_render" %}', environment: env).render!
end
end
@@ -102,7 +105,33 @@ class RenderTagTest < Minitest::Test
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
end
def test_include_tag_caches_second_read_of_same_partial
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
)
end
with_error_modes(:strict2) do
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% render "snippet" | filter %}',
)
end
end
def test_optional_commas
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
end
def test_render_tag_caches_second_read_of_same_partial
file_system = StubFileSystem.new('snippet' => 'echo')
assert_equal(
'echoecho',
@@ -145,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
@@ -285,4 +314,30 @@ class RenderTagTest < Minitest::Test
render_errors: true,
)
end
def test_render_with_invalid_expression
template = '{% render "snippet" with foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_render_attribute_with_invalid_expression
template = '{% render "snippet", key: foo=>bar %}'
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+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
+213 -3
View File
@@ -138,7 +138,7 @@ class TableRowTest < Minitest::Test
def test_tablerow_loop_drop_attributes
template = <<~LIQUID.chomp
{% tablerow i in (1...2) %}
{% tablerow i in (1..2) %}
col: {{ tablerowloop.col }}
col0: {{ tablerowloop.col0 }}
col_first: {{ tablerowloop.col_first }}
@@ -192,12 +192,14 @@ class TableRowTest < Minitest::Test
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
error_mode: :warn,
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
error_mode: :warn,
render_errors: true,
)
@@ -205,18 +207,19 @@ class TableRowTest < Minitest::Test
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
render_errors: true,
error_mode: :warn,
)
end
def test_table_row_handles_interrupts
assert_template_result(
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
'{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
'{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
)
assert_template_result(
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td><td class=\"col2\"> 2 </td></tr>\n<tr class=\"row2\"><td class=\"col1\"> 3 </td></tr>\n",
'{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
'{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
)
end
@@ -255,4 +258,211 @@ class TableRowTest < Minitest::Test
template,
)
end
def test_tablerow_with_cols_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
<tr class="row2"><td class="col1">4</td><td class="col2">5</td><td class="col3">6</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_limit_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_offset_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_range_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_multiple_attributes_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">2</td><td class="col2">3</td></tr>
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_variable_collection_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td></tr>
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
end
def test_tablerow_with_dotted_access_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td></tr>
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
end
end
def test_tablerow_with_bracketed_access_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">10</td><td class="col2">20</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
end
def test_tablerow_without_attributes_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_without_in_keyword_in_strict2_mode
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
end
end
def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
end
end
def test_tablerow_with_empty_collection_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
</tr>
OUTPUT
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'empty_array' => [] })
end
end
def test_tablerow_with_invalid_attribute_strict_vs_strict2
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td><td class="col4">4</td><td class="col5">5</td></tr>
OUTPUT
with_error_modes(:lax, :strict) do
assert_template_result(expected, template)
end
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
end
end
def test_tablerow_with_invalid_expression_strict_vs_strict2
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
with_error_modes(:lax, :strict) do
expected = <<~OUTPUT
<tr class="row1">
</tr>
OUTPUT
assert_template_result(expected, template)
end
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+360
View File
@@ -0,0 +1,360 @@
# frozen_string_literal: true
require_relative "../test_helper"
require "tmpdir"
class TemplateRecorderTest < Minitest::Test
class ProductDrop < Liquid::Drop
def initialize(title, secret)
super()
@title = title
@secret = secret
end
attr_reader :title
def details
DetailsDrop.new
end
end
class DetailsDrop < Liquid::Drop
def count
3
end
end
class LegacyFileSystem
attr_reader :reads
def initialize
@reads = []
end
def read_template_file(name)
@reads << name
"partial={{ product.title }}"
end
end
class UnsupportedLiquidValue
def to_liquid
self
end
def to_s
"unsupported"
end
end
class WrapperTag < Liquid::Block
end
class MarkerTag < Liquid::Tag
def render(_context)
"custom"
end
end
class CollectingWriter
attr_reader :records
def initialize
@records = []
end
def write(record)
@records << record
end
end
def setup
@directory = Dir.mktmpdir
end
def teardown
FileUtils.remove_entry(@directory)
end
def path(name = "recording.json")
File.join(@directory, name)
end
def test_records_and_verifies_a_render_without_changing_drop_behavior
product = ProductDrop.new("Computed", "must not be recorded")
template_source = "{{ product.title }} ({{ product.details.count }})"
output = Liquid::TemplateRecorder.record(path) do
Liquid::Template.parse(template_source).render!("product" => product)
end
assert_equal("Computed (3)", output)
record = Liquid::TemplateRecorder.records(path).first
assert_equal({ "title" => "Computed", "details" => { "count" => 3 } }, record.dig("assigns", "product"))
refute_includes(File.read(path), "must not be recorded")
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :verify).render)
end
def test_preserves_the_legacy_one_argument_file_system_contract
file_system = LegacyFileSystem.new
environment = Liquid::Environment.build { |env| env.file_system = file_system }
output = Liquid::TemplateRecorder.record(path) do
Liquid::Template.parse("before {% include 'card' %}", environment: environment)
.render!("product" => ProductDrop.new("Hat", "secret"))
end
assert_equal("before partial=Hat", output)
assert_equal(["card"], file_system.reads)
record = Liquid::TemplateRecorder.records(path).first
assert_equal({ "card" => "partial={{ product.title }}" }, record["file_system"])
assert_equal(2, record["templates"].length)
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :verify).render)
end
def test_jsonl_appends_one_self_contained_record_per_render
recording = path("renders.jsonl")
2.times do |sequence|
Liquid::TemplateRecorder.record(recording) do
Liquid::Template.parse("value={{ value }}").render!("value" => sequence)
end
end
assert_equal(2, File.readlines(recording).length)
assert_equal(["value=0", "value=1"], Liquid::TemplateRecorder.records(recording).map { |item| item["output"] })
assert_equal("value=0", Liquid::TemplateRecorder.replay_from(recording, index: 0).render)
assert_equal("value=1", Liquid::TemplateRecorder.replay_from(recording).render)
end
def test_json_session_supports_multiple_renders
Liquid::TemplateRecorder.record(path) do
Liquid::Template.parse("one={{ value }}").render!("value" => 1)
Liquid::Template.new.parse("two={{ value }}").render!("value" => 2)
end
records = Liquid::TemplateRecorder.records(path)
assert_equal(["one=1", "two=2"], records.map { |item| item["output"] })
assert_equal("two=2", Liquid::TemplateRecorder.replay_from(path).render)
end
def test_failed_recording_does_not_delete_an_existing_json_file
File.write(path, "existing")
assert_raises(RuntimeError) do
Liquid::TemplateRecorder.record(path) { raise "boom" }
end
assert_equal("existing", File.read(path))
end
def test_render_failure_is_not_written_to_jsonl
recording = path("renders.jsonl")
assert_raises(Liquid::UndefinedVariable) do
Liquid::TemplateRecorder.record(recording) do
Liquid::Template.parse("{{ missing }}").render!(nil, strict_variables: true)
end
end
refute_path_exists(recording)
end
def test_tampered_template_is_rejected
Liquid::TemplateRecorder.record(path) { Liquid::Template.parse("safe").render! }
session = JSON.parse(File.read(path))
session["renders"][0]["template"]["source"] = "changed"
File.write(path, JSON.generate(session))
error = assert_raises(Liquid::TemplateRecorder::ReplayError) do
Liquid::TemplateRecorder.replay_from(path)
end
assert_match(/checksum/, error.message)
end
def test_recordings_are_thread_local
paths = [path("a.json"), path("b.json")]
ready = Queue.new
release = Queue.new
threads = Array.new(2) do |index|
Thread.new do
Liquid::TemplateRecorder.record(paths[index]) do
ready << true
release.pop
Liquid::Template.parse("thread={{ value }}").render!("value" => index)
end
end
end
2.times { ready.pop }
2.times { release << true }
threads.each(&:join)
assert_equal("thread=0", Liquid::TemplateRecorder.records(paths[0]).first["output"])
assert_equal("thread=1", Liquid::TemplateRecorder.records(paths[1]).first["output"])
end
def test_nested_sessions_fail_without_corrupting_outer_session
error = nil
Liquid::TemplateRecorder.record(path) do
error = assert_raises(Liquid::TemplateRecorder::Error) do
Liquid::TemplateRecorder.record(path("inner.json")) { flunk }
end
Liquid::Template.parse("outer").render!
end
assert_match(/nested/, error.message)
assert_equal("outer", Liquid::TemplateRecorder.replay_from(path).render)
end
def test_supported_render_argument_forms_keep_working
filter = Module.new do
def decorate(input)
"[#{input}]"
end
end
template = nil
context = Liquid::Context.new([{ "value" => "context" }])
Liquid::TemplateRecorder.record(path) do
template = Liquid::Template.parse("{{ value | decorate }}")
assert_equal("[hash]", template.render({ "value" => "hash" }, filter))
context.add_filters(filter)
assert_equal("[context]", template.render(context))
end
assert_equal(2, Liquid::TemplateRecorder.records(path).length)
end
def test_strict_replay_uses_exact_recorded_filter_outputs
filter = Module.new do
def external_lookup(_input)
"x" * 150
end
end
output = Liquid::TemplateRecorder.record(path) do
Liquid::Template.parse("{{ key | external_lookup }}").render!({ "key" => "a" }, filter)
end
assert_equal("x" * 150, output)
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :strict).render)
assert_equal("a", Liquid::TemplateRecorder.replay_from(path, mode: :compute).render)
end
def test_jsonl_reader_ignores_only_a_truncated_final_record
recording = path("renders.jsonl")
Liquid::TemplateRecorder.record(recording) { Liquid::Template.parse("complete").render! }
File.open(recording, "ab") { |file| file.write('{"format":') }
assert_equal(["complete"], Liquid::TemplateRecorder.records(recording).map { |item| item["output"] })
end
def test_accepts_a_pluggable_writer
writer = CollectingWriter.new
output = Liquid::TemplateRecorder.record(writer) do
Liquid::Template.parse("Hello {{ name }}").render!("name" => "Shopify")
end
assert_equal("Hello Shopify", output)
assert_equal(["Hello Shopify"], writer.records.map { |record| record["output"] })
end
def test_records_only_variables_resolved_by_the_template
unused = Object.new
assigns = { "visible" => "yes", "unused" => unused }
Liquid::TemplateRecorder.record(path) do
Liquid::Template.parse("{{ visible }}").render!(assigns)
end
assert_equal({ "visible" => "yes" }, Liquid::TemplateRecorder.records(path).first["assigns"])
end
def test_recording_scope_is_fiber_local
writer = CollectingWriter.new
ordinary_output = nil
Liquid::TemplateRecorder.record(writer) do
Fiber.new do
ordinary_output = Liquid::Template.parse("ordinary").render!
end.resume
Liquid::Template.parse("recorded").render!
end
assert_equal("ordinary", ordinary_output)
assert_equal(["recorded"], writer.records.map { |record| record["output"] })
end
def test_on_error_keeps_recording_failures_out_of_the_render_path
writer = Object.new
writer.define_singleton_method(:write) { |_record| raise "sink unavailable" }
errors = []
output = Liquid::TemplateRecorder.record(writer, on_error: errors.method(:<<)) do
Liquid::Template.parse("still rendered").render!
end
assert_equal("still rendered", output)
assert_equal(["sink unavailable"], errors.map(&:message))
end
def test_unsupported_accessed_values_do_not_affect_the_render
value = UnsupportedLiquidValue.new
writer = CollectingWriter.new
output = Liquid::TemplateRecorder.record(writer) do
Liquid::Template.parse("{{ value }}").render!("value" => value)
end
assert_equal(value.to_s, output)
assert_equal({}, writer.records.first["assigns"])
end
def test_strict_replay_accepts_the_application_environment
environment = Liquid::Environment.build(
tags: Liquid::Environment.default.tags.merge("marker" => MarkerTag),
)
writer = CollectingWriter.new
Liquid::TemplateRecorder.record(writer) do
Liquid::Template.parse("{% marker %}", environment: environment).render!
end
replay = Liquid::TemplateRecorder::Replayer.new(
writer.records.first,
mode: :strict,
environment: environment,
)
assert_equal("custom", replay.render)
end
def test_captures_file_reads_that_happen_before_the_template_starts_rendering
writer = CollectingWriter.new
Liquid::TemplateRecorder.record(writer) do
Liquid::TemplateRecorder.current.emit_file_read("card", "Card")
file_system = LegacyFileSystem.new
Liquid::Template.parse("{% render 'card' %}").render!({}, registers: { file_system: file_system })
end
replay = Liquid::TemplateRecorder::Replayer.new(writer.records.first, mode: :strict)
assert_equal("partial=", replay.render)
end
def test_strict_replay_skips_nested_custom_tag_calls
environment = Liquid::Environment.build(
tags: Liquid::Environment.default.tags.merge("wrapper" => WrapperTag, "marker" => MarkerTag),
)
writer = CollectingWriter.new
Liquid::TemplateRecorder.record(writer) do
Liquid::Template.parse("{% wrapper %}{% marker %}{% endwrapper %}", environment: environment).render!
end
record = writer.records.first
replay = Liquid::TemplateRecorder::Replayer.new(record, mode: :strict, environment: environment)
assert_equal(["wrapper"], record["tag_calls"].map { |call| call["name"] })
assert_equal("custom", replay.render)
end
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 %}")
+74 -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
@@ -130,18 +130,22 @@ class VariableTest < Minitest::Test
assert_template_result('bar', '{{ foo }}', { 'foo' => :bar })
end
def test_nested_array
assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] })
end
def test_dynamic_find_var
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_raw_value_variable
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_dynamic_find_var_with_drop
assert_template_result(
'bar',
'{{ [list[settings.zero]] }}',
'{{ self[list[settings.zero]] }}',
{
'list' => ['foo'],
'settings' => SettingsDrop.new("zero" => 0),
@@ -151,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),
@@ -205,4 +209,69 @@ class VariableTest < Minitest::Test
end
end
end
def test_filter_with_single_trailing_comma
template = '{{ "hello" | append: "world", }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('helloworld', template)
end
end
def test_multiple_filters_with_trailing_commas
template = '{{ "hello" | append: "1", | append: "2", }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('hello12', template)
end
end
def test_filter_with_colon_but_no_arguments
template = '{{ "test" | upcase: }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TEST', template)
end
end
def test_filter_chain_with_colon_no_args
template = '{{ "test" | append: "x" | upcase: }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
end
def test_combining_trailing_comma_and_empty_args
template = '{{ "test" | append: "x", | upcase: }}'
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
end
end
+58 -12
View File
@@ -13,12 +13,7 @@ if (env_mode = ENV['LIQUID_PARSER_MODE'])
puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym
end
Liquid::Template.error_mode = mode
if ENV['LIQUID_C'] == '1'
puts "-- LIQUID C"
require 'liquid/c'
end
Liquid::Environment.default.error_mode = mode
if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here.
@@ -39,7 +34,7 @@ module Minitest
def assert_template_result(
expected, template, assigns = {},
message: nil, partials: nil, error_mode: nil, render_errors: false,
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
template_factory: nil
)
file_system = StubFileSystem.new(partials || {})
@@ -87,12 +82,14 @@ module Minitest
Environment.dangerously_override(environment, &blk)
end
def with_error_mode(mode)
old_mode = Liquid::Template.error_mode
Liquid::Template.error_mode = mode
yield
def with_error_modes(*modes)
old_mode = Liquid::Environment.default.error_mode
modes.each do |mode|
Liquid::Environment.default.error_mode = mode
yield
end
ensure
Liquid::Template.error_mode = old_mode
Liquid::Environment.default.error_mode = old_mode
end
def with_custom_tag(tag_name, tag_class, &block)
@@ -151,6 +148,35 @@ class BooleanDrop < Liquid::Drop
end
end
class StringDrop < Liquid::Drop
include Comparable
def initialize(value)
super()
@value = value
end
def to_liquid_value
@value
end
def to_s
@value
end
def to_str
@value
end
def inspect
"#<StringDrop @value=#{@value.inspect}>"
end
def <=>(other)
to_liquid_value <=> Liquid::Utils.to_liquid_value(other)
end
end
class ErrorDrop < Liquid::Drop
def standard_error
raise Liquid::StandardError, 'standard error'
@@ -173,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
+7 -1
View File
@@ -47,12 +47,18 @@ class BlockUnitTest < Minitest::Test
)
end
def test_with_block
def test_comment_tag_with_block
template = Liquid::Template.parse(" {% comment %} {% endcomment %} ")
assert_equal([String, Comment, String], block_types(template.root.nodelist))
assert_equal(3, template.root.nodelist.size)
end
def test_doc_tag_with_block
template = Liquid::Template.parse(" {% doc %} {% enddoc %} ")
assert_equal([String, Doc, String], block_types(template.root.nodelist))
assert_equal(3, template.root.nodelist.size)
end
private
def block_types(nodelist)
+199 -2
View File
@@ -161,11 +161,208 @@ 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
def test_parse_expression_in_strict_mode
environment = Environment.build(error_mode: :strict)
parse_context = ParseContext.new(environment: environment)
result = Condition.parse_expression(parse_context, 'product.title')
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parse_expression_in_strict2_mode_raises_internal_error
environment = Environment.build(error_mode: :strict2)
parse_context = ParseContext.new(environment: environment)
error = assert_raises(Liquid::InternalError) do
Condition.parse_expression(parse_context, 'product.title')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_safe_true_in_strict2_mode
environment = Environment.build(error_mode: :strict2)
parse_context = ParseContext.new(environment: environment)
result = Condition.parse_expression(parse_context, 'product.title', safe: true)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
# Tests for blank? comparison without ActiveSupport
#
# Ruby's standard library does not include blank? on String, Array, Hash, etc.
# ActiveSupport adds blank? but Liquid must work without it. These tests verify
# that Liquid implements blank? semantics internally for use in templates like:
# {% if x == blank %}...{% endif %}
#
# The blank? semantics match ActiveSupport's behavior:
# - nil and false are blank
# - Strings are blank if empty or contain only whitespace
# - Arrays and Hashes are blank if empty
# - true and numbers are never blank
def test_blank_with_whitespace_string
# Template authors expect " " to be blank since it has no visible content.
# This matches ActiveSupport's String#blank? which returns true for whitespace-only strings.
@context['whitespace'] = ' '
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.new('whitespace'), '==', blank_literal)
end
def test_blank_with_empty_string
# An empty string has no content, so it should be considered blank.
# This is the most basic case of a blank string.
@context['empty_string'] = ''
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.new('empty_string'), '==', blank_literal)
end
def test_blank_with_empty_array
# Empty arrays have no elements, so they are blank.
# Useful for checking if a collection has items: {% if products == blank %}
@context['empty_array'] = []
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.new('empty_array'), '==', blank_literal)
end
def test_blank_with_empty_hash
# Empty hashes have no key-value pairs, so they are blank.
# Useful for checking if settings/options exist: {% if settings == blank %}
@context['empty_hash'] = {}
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', blank_literal)
end
def test_blank_with_nil
# nil represents "nothing" and is the canonical blank value.
# Unassigned variables resolve to nil, so this enables: {% if missing_var == blank %}
@context['nil_value'] = nil
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.new('nil_value'), '==', blank_literal)
end
def test_blank_with_false
# false is considered blank to match ActiveSupport semantics.
# This allows {% if some_flag == blank %} to work when flag is false.
@context['false_value'] = false
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.new('false_value'), '==', blank_literal)
end
def test_not_blank_with_true
# true is a definite value, not blank.
# Ensures {% if flag == blank %} works correctly for boolean flags.
@context['true_value'] = true
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.new('true_value'), '==', blank_literal)
end
def test_not_blank_with_number
# Numbers (including zero) are never blank - they represent actual values.
# 0 is a valid quantity, not the absence of a value.
@context['number'] = 42
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.new('number'), '==', blank_literal)
end
def test_not_blank_with_string_content
# A string with actual content is not blank.
# This is the expected behavior for most template string comparisons.
@context['string'] = 'hello'
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.new('string'), '==', blank_literal)
end
def test_not_blank_with_non_empty_array
# An array with elements has content, so it's not blank.
# Enables patterns like {% unless products == blank %}Show products{% endunless %}
@context['array'] = [1, 2, 3]
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.new('array'), '==', blank_literal)
end
def test_not_blank_with_non_empty_hash
# A hash with key-value pairs has content, so it's not blank.
# Useful for checking if configuration exists: {% if config != blank %}
@context['hash'] = { 'a' => 1 }
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal)
end
# Tests for empty? comparison without ActiveSupport
#
# empty? is distinct from blank? - it only checks if a collection has zero elements.
# For strings, empty? checks length == 0, NOT whitespace content.
# Ruby's standard library has empty? on String, Array, and Hash, but Liquid
# provides a fallback implementation for consistency.
def test_empty_with_empty_string
# An empty string ("") has length 0, so it's empty.
# Different from blank - empty is a stricter check.
@context['empty_string'] = ''
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal)
end
def test_empty_with_whitespace_string_not_empty
# Whitespace strings have length > 0, so they are NOT empty.
# This is the key difference between empty and blank:
# " ".empty? => false, but " ".blank? => true
@context['whitespace'] = ' '
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_false(VariableLookup.new('whitespace'), '==', empty_literal)
end
def test_empty_with_empty_array
# An array with no elements is empty.
# [].empty? => true
@context['empty_array'] = []
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_true(VariableLookup.new('empty_array'), '==', empty_literal)
end
def test_empty_with_empty_hash
# A hash with no key-value pairs is empty.
# {}.empty? => true
@context['empty_hash'] = {}
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal)
end
def test_nil_is_not_empty
# nil is NOT empty - empty? checks if a collection has zero elements.
# nil is not a collection, so it cannot be empty.
# This differs from blank: nil IS blank, but nil is NOT empty.
@context['nil_value'] = nil
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal)
end
private
def assert_evaluates_true(left, op, right)
@@ -2,7 +2,7 @@
require 'test_helper'
class StrainerFactoryUnitTest < Minitest::Test
class EnvironmentFilterTest < Minitest::Test
include Liquid
module AccessScopeFilters
@@ -16,8 +16,6 @@ class StrainerFactoryUnitTest < Minitest::Test
private :private_filter
end
StrainerFactory.add_global_filter(AccessScopeFilters)
module LateAddedFilter
def late_added_filter(_input)
"filtered"
@@ -25,24 +23,28 @@ class StrainerFactoryUnitTest < Minitest::Test
end
def setup
@context = Context.build
@environment = Liquid::Environment.build do |env|
env.register_filter(AccessScopeFilters)
end
@context = Context.build(environment: @environment)
end
def test_strainer
strainer = StrainerFactory.create(@context)
strainer = @environment.create_strainer(@context)
assert_equal(5, strainer.invoke('size', 'input'))
assert_equal("public", strainer.invoke("public_filter"))
end
def test_stainer_raises_argument_error
strainer = StrainerFactory.create(@context)
def test_strainer_raises_argument_error
strainer = @environment.create_strainer(@context)
assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1)
end
end
def test_stainer_argument_error_contains_backtrace
strainer = StrainerFactory.create(@context)
def test_strainer_argument_error_contains_backtrace
strainer = @environment.create_strainer(@context)
exception = assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1)
@@ -52,12 +54,13 @@ class StrainerFactoryUnitTest < Minitest::Test
/\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/,
exception.message,
)
source = AccessScopeFilters.instance_method(:public_filter).source_location
assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1])
assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1])
end
def test_strainer_only_invokes_public_filter_methods
strainer = StrainerFactory.create(@context)
strainer = @environment.create_strainer(@context)
assert_equal(false, strainer.class.invokable?('__test__'))
assert_equal(false, strainer.class.invokable?('test'))
assert_equal(false, strainer.class.invokable?('instance_eval'))
@@ -66,18 +69,18 @@ class StrainerFactoryUnitTest < Minitest::Test
end
def test_strainer_returns_nil_if_no_filter_method_found
strainer = StrainerFactory.create(@context)
strainer = @environment.create_strainer(@context)
assert_nil(strainer.invoke("private_filter"))
assert_nil(strainer.invoke("undef_the_filter"))
end
def test_strainer_returns_first_argument_if_no_method_and_arguments_given
strainer = StrainerFactory.create(@context)
strainer = @environment.create_strainer(@context)
assert_equal("password", strainer.invoke("undef_the_method", "password"))
end
def test_strainer_only_allows_methods_defined_in_filters
strainer = StrainerFactory.create(@context)
strainer = @environment.create_strainer(@context)
assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1"))
assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom"))
assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke"))
@@ -86,7 +89,9 @@ class StrainerFactoryUnitTest < Minitest::Test
def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation
a = Module.new
b = Module.new
strainer = StrainerFactory.create(@context, [a, b])
strainer = @environment.create_strainer(@context, [a, b])
assert_kind_of(StrainerTemplate, strainer)
assert_kind_of(a, strainer)
assert_kind_of(b, strainer)
@@ -94,8 +99,10 @@ class StrainerFactoryUnitTest < Minitest::Test
end
def test_add_global_filter_clears_cache
assert_equal('input', StrainerFactory.create(@context).invoke('late_added_filter', 'input'))
StrainerFactory.add_global_filter(LateAddedFilter)
assert_equal('filtered', StrainerFactory.create(nil).invoke('late_added_filter', 'input'))
assert_equal('input', @environment.create_strainer(@context).invoke('late_added_filter', 'input'))
@environment.register_filter(LateAddedFilter)
assert_equal('filtered', @environment.create_strainer(nil).invoke('late_added_filter', 'input'))
end
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'test_helper'
class EnvironmentTest < Minitest::Test
include Liquid
class UnsubscribeFooter < Liquid::Tag
def render(_context)
'Unsubscribe Footer'
end
end
def test_custom_tag
email_environment = Liquid::Environment.build do |environment|
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
end
assert(email_environment.tags["unsubscribe_footer"])
assert(email_environment.tag_for_name("unsubscribe_footer"))
template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment)
assert_equal('Unsubscribe Footer', template.render)
end
end
+11 -1
View File
@@ -131,9 +131,19 @@ class LexerUnitTest < Minitest::Test
assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1"))
end
def test_error_with_invalid_utf8
error = assert_raises(SyntaxError) do
tokenize("\x00\xff")
end
assert_equal(
'Liquid syntax error: Invalid byte sequence in UTF-8',
error.message,
)
end
private
def tokenize(input)
Lexer.new(input).tokenize
Lexer.tokenize(StringScanner.new(input))
end
end
+123
View File
@@ -0,0 +1,123 @@
# frozen_string_literal: true
require 'test_helper'
class ParseContextUnitTest < Minitest::Test
include Liquid
def test_safe_parse_expression_with_variable_lookup
parser_strict = strict_parse_context.new_parser('product.title')
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
parser_strict2 = strict2_parse_context.new_parser('product.title')
result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2)
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
assert_instance_of(VariableLookup, result_strict2)
assert_equal('product', result_strict2.name)
assert_equal(['title'], result_strict2.lookups)
end
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
parser_strict = strict_parse_context.new_parser('')
parser_strict2 = strict2_parse_context.new_parser('')
error_strict = assert_raises(Liquid::SyntaxError) do
strict_parse_context.safe_parse_expression(parser_strict)
end
assert_match(/is not a valid expression/, error_strict.message)
error_strict2 = assert_raises(Liquid::SyntaxError) do
strict2_parse_context.safe_parse_expression(parser_strict2)
end
assert_match(/is not a valid expression/, error_strict2.message)
end
def test_parse_expression_with_variable_lookup
result_strict = strict_parse_context.parse_expression('product.title')
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
error = assert_raises(Liquid::InternalError) do
strict2_parse_context.parse_expression('product.title')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_safe_true
result_strict = strict_parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result_strict2)
assert_equal('product', result_strict2.name)
assert_equal(['title'], result_strict2.lookups)
end
def test_parse_expression_with_empty_string
result_strict = strict_parse_context.parse_expression('')
assert_nil(result_strict)
error = assert_raises(Liquid::InternalError) do
strict2_parse_context.parse_expression('')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_empty_string_and_safe_true
result_strict = strict_parse_context.parse_expression('', safe: true)
assert_nil(result_strict)
result_strict2 = strict2_parse_context.parse_expression('', safe: true)
assert_nil(result_strict2)
end
def test_safe_parse_expression_advances_parser_pointer
parser = strict2_parse_context.new_parser('foo, bar')
# safe_parse_expression consumes "foo"
first_result = strict2_parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, first_result)
assert_equal('foo', first_result.name)
parser.consume(:comma)
# safe_parse_expression consumes "bar"
second_result = strict2_parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, second_result)
assert_equal('bar', second_result.name)
parser.consume(:end_of_string)
end
def test_parse_expression_with_whitespace_in_strict2_mode
result = strict2_parse_context.parse_expression(' ', safe: true)
assert_nil(result)
end
private
def strict_parse_context
@strict_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict),
)
end
def strict2_parse_context
@strict2_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict2),
)
end
end
+16 -10
View File
@@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test
include Liquid
def test_consume
p = Parser.new("wat: 7")
p = new_parser("wat: 7")
assert_equal('wat', p.consume(:id))
assert_equal(':', p.consume(:colon))
assert_equal('7', p.consume(:number))
end
def test_jump
p = Parser.new("wat: 7")
p = new_parser("wat: 7")
p.jump(2)
assert_equal('7', p.consume(:number))
end
def test_consume?
p = Parser.new("wat: 7")
p = new_parser("wat: 7")
assert_equal('wat', p.consume?(:id))
assert_equal(false, p.consume?(:dot))
assert_equal(':', p.consume(:colon))
@@ -27,7 +27,7 @@ class ParserUnitTest < Minitest::Test
end
def test_id?
p = Parser.new("wat 6 Peter Hegemon")
p = new_parser("wat 6 Peter Hegemon")
assert_equal('wat', p.id?('wat'))
assert_equal(false, p.id?('endgame'))
assert_equal('6', p.consume(:number))
@@ -36,7 +36,7 @@ class ParserUnitTest < Minitest::Test
end
def test_look
p = Parser.new("wat 6 Peter Hegemon")
p = new_parser("wat 6 Peter Hegemon")
assert_equal(true, p.look(:id))
assert_equal('wat', p.consume(:id))
assert_equal(false, p.look(:comparison))
@@ -46,12 +46,12 @@ class ParserUnitTest < Minitest::Test
end
def test_expressions
p = Parser.new("hi.there hi?[5].there? hi.there.bob")
p = new_parser("hi.there hi?[5].there? hi.there.bob")
assert_equal('hi.there', p.expression)
assert_equal('hi?[5].there?', p.expression)
assert_equal('hi.there.bob', p.expression)
p = Parser.new("567 6.0 'lol' \"wut\"")
p = new_parser("567 6.0 'lol' \"wut\"")
assert_equal('567', p.expression)
assert_equal('6.0', p.expression)
assert_equal("'lol'", p.expression)
@@ -59,7 +59,7 @@ class ParserUnitTest < Minitest::Test
end
def test_ranges
p = Parser.new("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
assert_equal('(5..7)', p.expression)
assert_equal('(1.5..9.6)', p.expression)
assert_equal('(young..old)', p.expression)
@@ -67,7 +67,7 @@ class ParserUnitTest < Minitest::Test
end
def test_arguments
p = Parser.new("filter: hi.there[5], keyarg: 7")
p = new_parser("filter: hi.there[5], keyarg: 7")
assert_equal('filter', p.consume(:id))
assert_equal(':', p.consume(:colon))
assert_equal('hi.there[5]', p.argument)
@@ -77,8 +77,14 @@ class ParserUnitTest < Minitest::Test
def test_invalid_expression
assert_raises(SyntaxError) do
p = Parser.new("==")
p = new_parser("==")
p.expression
end
end
private
def new_parser(str)
Parser.new(StringScanner.new(str))
end
end
+2 -2
View File
@@ -184,7 +184,7 @@ class PartialCacheUnitTest < Minitest::Test
},
)
[:lax, :warn, :strict].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: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
+11 -7
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)
@@ -25,11 +25,13 @@ class StrainerTemplateUnitTest < Minitest::Test
end
def test_add_filter_raises_when_module_privately_overrides_registered_public_methods
strainer = Context.new.strainer
error = assert_raises(Liquid::MethodOverrideError) do
strainer.class.add_filter(PrivateMethodOverrideFilter)
Liquid::Environment.build do |env|
env.register_filter(PublicMethodOverrideFilter)
env.register_filter(PrivateMethodOverrideFilter)
end
end
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end
@@ -42,11 +44,13 @@ class StrainerTemplateUnitTest < Minitest::Test
end
def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected
strainer = Context.new.strainer
error = assert_raises(Liquid::MethodOverrideError) do
strainer.class.add_filter(ProtectedMethodOverrideFilter)
Liquid::Environment.build do |env|
env.register_filter(PublicMethodOverrideFilter)
env.register_filter(ProtectedMethodOverrideFilter)
end
end
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end
+13 -4
View File
@@ -6,18 +6,18 @@ class TagUnitTest < Minitest::Test
include Liquid
def test_tag
tag = Tag.parse('tag', "", Tokenizer.new(""), ParseContext.new)
tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new)
assert_equal('liquid::tag', tag.name)
assert_equal('', tag.render(Context.new))
end
def test_return_raw_text_of_tag
tag = Tag.parse("long_tag", "param1, param2, param3", Tokenizer.new(""), ParseContext.new)
tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new)
assert_equal("long_tag param1, param2, param3", tag.raw)
end
def test_tag_name_should_return_name_of_the_tag
tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new)
assert_equal('some_tag', tag.tag_name)
end
@@ -26,7 +26,16 @@ class TagUnitTest < Minitest::Test
end
def test_tag_render_to_output_buffer_nil_value
custom_tag = CustomTag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new)
assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string"))
end
private
def new_tokenizer
Tokenizer.new(
source: "",
string_scanner: StringScanner.new(""),
)
end
end
+136
View File
@@ -9,4 +9,140 @@ class CaseTagUnitTest < Minitest::Test
template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}')
assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten)
end
def test_case_with_trailing_element
template = <<~LIQUID
{%- case 1 bar -%}
{%- when 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict) do
assert_template_result("one", template)
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
end
def test_case_when_with_trailing_element
template = <<~LIQUID
{%- case 1 -%}
{%- when 1 bar -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict) do
assert_template_result("one", template)
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
end
def test_case_when_with_comma
template = <<~LIQUID
{%- case 1 -%}
{%- when 2, 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
end
def test_case_when_with_or
template = <<~LIQUID
{%- case 1 -%}
{%- when 2 or 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
end
def test_case_when_empty
template = <<~LIQUID
{%- case x -%}
{%- when 2 or empty -%}
2 or empty
{%- else -%}
not 2 or empty
{%- endcase -%}
LIQUID
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("2 or empty", template, { 'x' => 2 })
assert_template_result("2 or empty", template, { 'x' => {} })
assert_template_result("2 or empty", template, { 'x' => [] })
assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
assert_template_result("not 2 or empty", template, { 'x' => 4 })
end
end
def test_case_with_invalid_expression
template = <<~LIQUID
{%- case foo=>bar -%}
{%- when 'baz' -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
with_error_modes(:lax, :strict) do
assert_template_result("one", template, assigns)
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_case_when_with_invalid_expression
template = <<~LIQUID
{%- case 'baz' -%}
{%- when foo=>bar -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
with_error_modes(:lax, :strict) do
assert_template_result("one", template, assigns)
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+287
View File
@@ -0,0 +1,287 @@
# frozen_string_literal: true
require 'test_helper'
class DocTagUnitTest < Minitest::Test
def test_doc_tag
template = <<~LIQUID.chomp
{% doc %}
Renders loading-spinner.
@param {string} foo - some foo
@param {string} [bar] - optional bar
@example
{% render 'loading-spinner', foo: 'foo' %}
{% render 'loading-spinner', foo: 'foo', bar: 'bar' %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_body_content
doc_content = " Documentation content\n @param {string} foo - test\n"
template_source = "{% doc %}#{doc_content}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(doc_content, doc_tag.nodelist.first.to_s)
end
def test_doc_tag_does_not_support_extra_arguments
error = assert_raises(Liquid::SyntaxError) do
template = <<~LIQUID.chomp
{% doc extra %}
{% enddoc %}
LIQUID
Liquid::Template.parse(template)
end
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}"
act_error = error.message
assert_equal(exp_error, act_error)
end
def test_doc_tag_must_support_valid_tags
assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo')
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}')
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}')
end
def test_doc_tag_ignores_liquid_nodes
template = <<~LIQUID.chomp
{% doc %}
{% if true %}
{% if ... %}
{%- for ? -%}
{% while true %}
{%
unless if
%}
{% endcase %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_ignores_unclosed_liquid_tags
template = <<~LIQUID.chomp
{% doc %}
{% if true %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_does_not_allow_nested_docs
error = assert_raises(Liquid::SyntaxError) do
template = <<~LIQUID.chomp
{% doc %}
{% doc %}
{% doc %}
{% enddoc %}
LIQUID
Liquid::Template.parse(template)
end
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed"
act_error = error.message
assert_equal(exp_error, act_error)
end
def test_doc_tag_ignores_nested_raw_tags
template = <<~LIQUID.chomp
{% doc %}
{% raw %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_ignores_unclosed_assign
template = <<~LIQUID.chomp
{% doc %}
{% assign foo = "1"
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_ignores_malformed_syntax
template = <<~LIQUID.chomp
{% doc %}
{% {{ {%- enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_captures_token_before_enddoc
template_source = "{% doc %}{{ incomplete{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s)
end
def test_doc_tag_preserves_error_line_numbers
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
{% doc %}
{% if true %}
{% enddoc %}
{{ errors.standard_error }}
LIQUID
expected = <<~TEXT.chomp
Liquid error (line 4): standard error
TEXT
assert_equal(expected, template.render('errors' => ErrorDrop.new))
end
def test_doc_tag_whitespace_control
# Basic whitespace control
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!")
assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!")
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!")
assert_template_result("Hello!", <<~LIQUID.chomp)
{%- doc %}Whitespace control!{% enddoc -%}
Hello!
LIQUID
end
def test_doc_tag_delimiter_handling
assert_template_result('', <<~LIQUID.chomp)
{%- if true -%}
{%- doc -%}
{%- docEXTRA -%}wut{% enddocEXTRA -%}xyz
{%- enddoc -%}
{%- endif -%}
LIQUID
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
assert_template_result('', "{% doc %}123{% enddoc\txyz %}")
assert_template_result('', "{% doc %}123{% enddoc\nxyz %}")
assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}")
end
def test_doc_tag_visitor
template_source = '{% doc %}{% enddoc %}'
assert_equal(
[Liquid::Doc],
visit(template_source),
)
end
def test_doc_tag_blank_with_empty_content
template_source = "{% doc %}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(true, doc_tag.blank?)
end
def test_doc_tag_blank_with_content
template_source = "{% doc %}Some documentation{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(false, doc_tag.blank?)
end
def test_doc_tag_blank_with_whitespace_only
template_source = "{% doc %} {% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal(false, doc_tag.blank?)
end
def test_doc_tag_nodelist_returns_array_with_body
doc_content = "Documentation content\n@param {string} foo"
template_source = "{% doc %}#{doc_content}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal([doc_content], doc_tag.nodelist)
assert_equal(1, doc_tag.nodelist.length)
assert_equal(doc_content, doc_tag.nodelist.first)
end
def test_doc_tag_nodelist_with_empty_content
template_source = "{% doc %}{% enddoc %}"
doc_tag = nil
ParseTreeVisitor
.for(Template.parse(template_source).root)
.add_callback_for(Liquid::Doc) do |tag|
doc_tag = tag
end
.visit
assert_equal([""], doc_tag.nodelist)
assert_equal(1, doc_tag.nodelist.length)
end
private
def traversal(template)
ParseTreeVisitor
.for(Template.parse(template).root)
.add_callback_for(Liquid::Doc) do |tag|
tag_class = tag.class
tag_class
end
end
def visit(template)
traversal(template).visit.flatten.compact
end
end
+11 -19
View File
@@ -20,25 +20,6 @@ class TemplateUnitTest < Minitest::Test
assert_equal(fixture("en_locale.yml"), locale.path)
end
def test_with_cache_classes_tags_returns_the_same_class
original_cache_setting = Liquid.cache_classes
Liquid.cache_classes = true
original_klass = Class.new
Object.send(:const_set, :CustomTag, original_klass)
Template.register_tag('custom', CustomTag)
Object.send(:remove_const, :CustomTag)
new_klass = Class.new
Object.send(:const_set, :CustomTag, new_klass)
assert(Template.tags['custom'].equal?(original_klass))
ensure
Object.send(:remove_const, :CustomTag)
Liquid.cache_classes = original_cache_setting
end
class FakeTag; end
def test_tags_can_be_looped_over
@@ -54,4 +35,15 @@ class TemplateUnitTest < Minitest::Test
def test_template_inheritance
assert_equal("foo", TemplateSubclass.parse("foo").render)
end
def test_invalid_utf8
input = "\xff\x00"
error = assert_raises(SyntaxError) do
Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input))
end
assert_equal(
'Liquid syntax error: Invalid byte sequence in UTF-8',
error.message,
)
end
end

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