Compare commits

...
Author SHA1 Message Date
Charles-P. ClermontandClaude Opus 4.5 26cb29487b fix(parser): implement RTL associativity for boolean expressions
- Use right-recursive descent for proper RTL precedence
- Add bin/liquid-spec-all-adapters for CI workflow
- Update CI to use dedicated script

Fixes 128 precedence test failures from original implementation.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-28 09:09:19 -05:00
Charles-P. Clermont 3b13ee0ff3 Remove default_context test from Condition 2026-01-27 10:07:36 -05:00
Charles-P. Clermont ef4770975b Fix MethodLiteral for updated blank & empty 2026-01-27 10:07:36 -05:00
Charles-P. Clermont a4e82eb5c1 Annotate Parser with grammar rules 2026-01-27 10:07:35 -05:00
Charles-P. Clermont 785427d85c Update History.md 2026-01-27 10:07:35 -05:00
Charles-P. Clermont 10c0de74b5 Add support for parenthesized expressions 2026-01-27 10:07:35 -05:00
Charles-P. Clermont f9b5313020 Prepare logical for grouping 2026-01-27 10:07:35 -05:00
Charles-P. Clermont 7f414e5fa6 Remove Condition#{child_relation,and,or}
- Remove Condition#child_relation
- Remove Condition#and
- Remove Condition#or
- Simplify Condition#evaluate

This logic was moved to the Parser & BinaryExpression
2026-01-27 10:07:35 -05:00
Charles-P. Clermont 429711fd82 Add support for logical expressions 2026-01-26 16:53:38 -05:00
Charles-P. Clermont e911eea3df Remove Condition.op, Condition.right
- Remove comparison expression logic
2026-01-26 16:52:46 -05:00
Charles-P. Clermont abbd342b22 Remove Condition.operators feature that let you define operators
It was incompatible with the lexer anyway. Not used internally either.
2026-01-26 16:52:31 -05:00
Charles-P. Clermont daae2186d2 Make Condition unit tests go through BinaryExpression
Instead of having Condition parse the left op right, make
BinaryExpression do it. Make sure all the tests pass as they
used to in the process.
2026-01-26 16:52:31 -05:00
Charles-P. Clermont 83f0ac5424 Add boolean expressions as variable unit tests 2026-01-26 16:52:31 -05:00
Charles-P. Clermont f60d3f0a41 Use BinaryExpression instead of Condition for comparisons 2026-01-26 16:52:31 -05:00
Charles-P. Clermont 0b890904f0 Make Parser.expression parse equality expressions 2026-01-26 16:52:31 -05:00
Charles-P. Clermont b161dae495 Make Parser.expression parse comparisons 2026-01-26 16:52:31 -05:00
Charles-P. ClermontandClaude Opus 4.5 6f03452245 style: fix rubocop offenses
Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-26 16:52:21 -05:00
Charles-P. ClermontandClaude Opus 4.5 1b420ab6d8 fix(case): use Condition.parse_expression for empty/blank handling
Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-26 16:52:21 -05:00
Charles-P. Clermont 18f1e8a61e Replace Context[]'s Expression.parse with Parser#expression 2026-01-26 16:52:17 -05:00
Charles-P. Clermont dff829ec72 Extract Parser#range_lookup out of Expression.parse 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 8b04c52ab2 Extract Parser#variable_lookup out of Expression.parse 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 8efcf7dd3a Move VariableLookup parsing logic to .parse instead of initializer
Goal is to get rid of it entirely, but baby steps.
2026-01-26 16:52:17 -05:00
Charles-P. Clermont af774ebc55 Replace RangeLookup.parse with RangeLookup.create 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 288c4eaac3 Simplify parse_number
We don't need all the multi-dot logic in a world where number comes
out of the Lexer.
2026-01-26 16:52:17 -05:00
Charles-P. Clermont e1e0129983 Extract Parser#number parse out of Expression.parse 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 2a86852eb3 Extract Parser#string out of Expression.parse 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 85d73e462d Remove Expression#safe_parse 2026-01-26 16:52:17 -05:00
Charles-P. Clermont c9ae128354 Rename Parser#expression_node -> Parser#expression 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 6cfcd6cac0 Rename Parser#argument -> argument_string 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 6b78291449 Rename Parser#expression -> Parser#expression_string 2026-01-26 16:52:17 -05:00
Charles-P. Clermont 7d0bdd166d Move parse_expression to Parser.unsafe_parse_expression
- Add Parser#string
- Add Parser#unsafe_parse_expression
- Add private Parser#parse_expression
- Remove ParseContext.parse_expression
- Remove Tag.parse_expression
- Condition.parse_expression now takes a parser as argument
2026-01-26 16:52:17 -05:00
Charles-P. Clermont cf3d845315 Move safe_parse_expression into Parser.expression_node
- Make `Parser` accept the expression cache
- Remove `safe_parse_expression` from `ParseContext`
- Replace all usage of `safe_parse_expression` with `parser.expression_node`
2026-01-26 16:52:17 -05:00
Charles-P. Clermont 851584327a Remove warnings system 2026-01-26 16:52:17 -05:00
Charles-P. ClermontandClaude Opus 4.5 9a3057f8e6 Point liquid-spec to cp-gate-lax-specs-ai branch
This branch gates lax-mode-only specs behind lax_parsing feature:
- shopify_production_recordings: 22 specs
- liquid_ruby: 16 specs
- basics: error regex updated

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-26 16:52:06 -05:00
Charles-P. Clermont f4a5454cd6 Remove warnings system 2026-01-14 09:55:21 -05:00
Charles-P. Clermont 40b1f83f25 Update changelog with planned changes for 6.0.0 2026-01-14 09:55:21 -05:00
Charles-P. Clermont 62400f71d0 Remove :error_mode 2026-01-14 09:55:20 -05:00
Charles-P. Clermont b7fa6a89b3 Rename strict2_parse to parse_markup 2026-01-14 09:54:19 -05:00
Charles-P. Clermont d936ce4ac9 Remove :lax, :strict and :warn error modes 2026-01-14 09:54:19 -05: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
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
Justin D. HarrisandGitHub aefd48e341 template.rb: Correct typo in docs 2025-04-22 09:50:47 -04:00
73 changed files with 2243 additions and 1041 deletions
+33 -17
View File
@@ -1,5 +1,5 @@
name: Liquid
on: [push, pull_request]
on: [push]
env:
BUNDLE_JOBS: 4
@@ -9,30 +9,33 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
entry:
- { ruby: 3.0, allowed-failure: false } # minimum supported
- { ruby: 3.2, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false }
- { ruby: 3.4, allowed-failure: false } # latest
- {
ruby: 3.4,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 3.3, allowed-failure: false } # minimum supported
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: ruby-head, allowed-failure: false }
- { ruby: 4.0, allowed-failure: false } # latest stable
- {
ruby: ruby-head,
ruby: 4.0,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
name: Test Ruby ${{ matrix.entry.ruby }}
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
# Head can have failures due to being in development
- { ruby: head, allowed-failure: true }
- {
ruby: head,
allowed-failure: true,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: head, allowed-failure: true, rubyopt: "--yjit" }
- { ruby: head, allowed-failure: true, rubyopt: "--zjit" }
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
@@ -42,11 +45,24 @@ jobs:
env:
RUBYOPT: ${{ matrix.entry.rubyopt }}
spec:
runs-on: ubuntu-latest
env:
BUNDLE_WITH: spec
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
with:
bundler-cache: true
bundler: latest
- name: Run liquid-spec for all adapters
run: bin/liquid-spec-all-adapters
memory_profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.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'
+9 -2
View File
@@ -25,7 +25,14 @@ group :development do
end
group :test do
gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'benchmark'
gem 'rubocop', '~> 1.82.0'
gem 'rubocop-shopify', '~> 2.18.0', require: false
gem 'rubocop-performance', require: false
end
group :spec do
# TODO: temporary - using cp-gate-lax-specs-ai branch until lax specs are properly gated
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'cp-gate-lax-specs-ai'
gem 'activesupport', require: false
end
+60
View File
@@ -1,5 +1,65 @@
# Liquid Change Log
## 6.0.0
### Features
* Add support for boolean expressions everywhere
* As variable output `{{ a or b }}`
* As filter argument `{{ collection | where: 'prop', a or b }}`
* As tag argument `{% render 'snip', enabled: a or b %}`
* As conditional tag argument `{% if cond %}` (extending previous behaviour)
* Add support for subexpression prioritization and associativity
* In ascending order of priority:
* Logical: `and`, `or` (right to left)
* Equality: `==`, `!=`, `<>` (left to right)
* Comparison: `>`, `>=`, `<`, `<=`, `contains` (left to right)
* Groupings: `( expr )`
- For example, this is now supported
* `{{ a > b == c < d or e == f }}` which is equivalent to
* `{{ ((a > b) == (c < d)) or (e == f) }}`
- Add support for parenthesized expressions
* e.g. `(a or b) == c`
### Architectural changes
* `parse_expression` and `safe_parse_expression` have been removed from `Tag` and `ParseContext`
* `Parser` methods now produce AST nodes instead of strings
* `Parser#expression` produces a value,
* `Parser#string` produces a string,
* etc.
### Breaking changes
* The Environment's `error_mode` option has been removed.
* `:warn` is no longer supported
* `:lax` and `lax_parse` is no longer supported
* `:strict` and `strict_parse` is no longer supported
* `strict2_parse` is renamed to `parse_markup`
* Expressions
* The `warnings` system has been removed.
* `Parser#expression` is renamed to `Parser#expression_string`
* `safe_parse_expression` methods are replaced by `Parser#expression`
* `parse_expression` methods are replaced by `Parser#unsafe_parse_expression`
* `Condition`
* `new(expr)` no longer accepts an `op` or `right`. Logic moved to BinaryExpression.
* `Condition#or` and `Condition#and` were replaced by `BinaryExpression`.
* `Condition#child_relation` replaced by `BinaryExpression`.
* `Condition.operations` was removed.
* `Condtion::MethodLiteral` was moved to the `Liquid` namespace
### Migrating from `^5.11.0`
- In custom tags that include `ParserSwitching`, rename `strict2_parse` to `parse_markup`
- Remove code depending on `:error_mode`
- Replace `safe_parse_expression` calls with `Parser#expression`
## 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]
-25
View File
@@ -93,31 +93,6 @@ LIQUID
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
### Error Modes
Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted.
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
it very hard to debug and can lead to unexpected behaviour.
Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
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.
```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
```ruby
Liquid::Template.parse(source, error_mode: :strict)
```
This is useful for doing things like enabling strict mode only in the theme editor.
It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created.
It is also recommended that you use it in the template editors of existing apps to give editors better error messages.
### Undefined variables and filters
By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method.
+10 -26
View File
@@ -33,21 +33,12 @@ task :rubocop do
end
end
desc('runs test suite with both strict and lax parsers')
desc('runs test suite')
task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict'
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
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
@@ -70,19 +61,11 @@ task release: :build do
end
namespace :benchmark do
desc "Run the liquid benchmark with lax parsing"
task :lax do
ruby "./performance/benchmark.rb lax"
desc "Run the liquid benchmark"
task :run do
ruby "./performance/benchmark.rb"
end
desc "Run the liquid benchmark with strict parsing"
task :strict do
ruby "./performance/benchmark.rb strict"
end
desc "Run the liquid benchmark with both lax and strict parsing"
task run: [:lax, :strict]
desc "Run unit benchmarks"
namespace :unit do
task :all do
@@ -113,11 +96,6 @@ namespace :profile do
task :run do
ruby "./performance/profile.rb"
end
desc "Run the liquid profile/performance coverage with strict parsing"
task :strict do
ruby "./performance/profile.rb strict"
end
end
namespace :memory_profile do
@@ -135,3 +113,9 @@ end
task :console do
exec 'irb -I lib -r liquid'
end
desc('run liquid-spec suite across all adapters')
task :spec do
adapters = Dir['./spec/*.rb'].join(',')
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
end
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
for adapter in spec/*.rb; do
echo "=== Running $adapter ==="
bundle exec liquid-spec run "$adapter" --no-max-failures
done
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)
+2
View File
@@ -62,6 +62,8 @@ require 'liquid/interrupts'
require 'liquid/tags'
require "liquid/environment"
require 'liquid/lexer'
require 'liquid/method_literal'
require 'liquid/binary_expression'
require 'liquid/parser'
require 'liquid/i18n'
require 'liquid/drop'
+94
View File
@@ -0,0 +1,94 @@
# frozen_string_literal: true
module Liquid
class BinaryExpression
attr_reader :operator
attr_accessor :left_node, :right_node
def initialize(left, operator, right)
@left_node = left
@operator = operator
@right_node = right
end
def evaluate(context)
left = value(left_node, context)
# logical relation short circuiting
if operator == 'and'
return left && value(right_node, context)
elsif operator == 'or'
return left || value(right_node, context)
end
right = value(right_node, context)
case operator
when '>'
left > right if can_compare?(left, right)
when '>='
left >= right if can_compare?(left, right)
when '<'
left < right if can_compare?(left, right)
when '<='
left <= right if can_compare?(left, right)
when '=='
equal_variables(left, right)
when '!=', '<>'
!equal_variables(left, right)
when 'contains'
contains(left, right)
else
raise(Liquid::ArgumentError, "Unknown operator #{operator}")
end
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message
end
def to_s
"(#{left_node.inspect} #{operator} #{right_node.inspect})"
end
private
def value(expr, context)
Utils.to_liquid_value(context.evaluate(expr))
end
def can_compare?(left, right)
left.respond_to?(operator) && right.respond_to?(operator) && !left.is_a?(Hash) && !right.is_a?(Hash)
end
def contains(left, right)
if left && right && left.respond_to?(:include?)
right = right.to_s if left.is_a?(String)
left.include?(right)
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end
def apply_method_literal(node, other)
node.apply(other)
end
def equal_variables(left, right)
return apply_method_literal(left, right) if left.is_a?(MethodLiteral)
return apply_method_literal(right, left) if right.is_a?(MethodLiteral)
left == right
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
@node.left_node,
@node.right_node,
]
end
end
end
end
+8 -125
View File
@@ -5,92 +5,19 @@ module Liquid
#
# Example:
#
# c = Condition.new(1, '==', 1)
# c = Condition.new(expr)
# c.evaluate #=> true
#
class Condition # :nodoc:
@@operators = {
'==' => ->(cond, left, right) { cond.send(:equal_variables, left, right) },
'!=' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) },
'<>' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) },
'<' => :<,
'>' => :>,
'>=' => :>=,
'<=' => :<=,
'contains' => lambda do |_cond, left, right|
if left && right && left.respond_to?(:include?)
right = right.to_s if left.is_a?(String)
left.include?(right)
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end,
}
attr_reader :attachment
attr_accessor :left
class MethodLiteral
attr_reader :method_name, :to_s
def initialize(method_name, to_s)
@method_name = method_name
@to_s = to_s
end
end
@@method_literals = {
'blank' => MethodLiteral.new(:blank?, '').freeze,
'empty' => MethodLiteral.new(:empty?, '').freeze,
}
def self.operators
@@operators
end
def self.parse_expression(parse_context, markup)
@@method_literals[markup] || parse_context.parse_expression(markup)
end
attr_reader :attachment, :child_condition
attr_accessor :left, :operator, :right
def initialize(left = nil, operator = nil, right = nil)
@left = left
@operator = operator
@right = right
@child_relation = nil
@child_condition = nil
def initialize(left = nil)
@left = left
end
def evaluate(context = deprecated_default_context)
condition = self
result = nil
loop do
result = interpret_condition(condition.left, condition.right, condition.operator, context)
case condition.child_relation
when :or
break if Liquid::Utils.to_liquid_value(result)
when :and
break unless Liquid::Utils.to_liquid_value(result)
else
break
end
condition = condition.child_condition
end
result
end
def or(condition)
@child_relation = :or
@child_condition = condition
end
def and(condition)
@child_relation = :and
@child_condition = condition
context.evaluate(left)
end
def attach(attachment)
@@ -111,51 +38,9 @@ module Liquid
private
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
end
if right.is_a?(MethodLiteral)
if left.respond_to?(right.method_name)
return left.send(right.method_name)
else
return nil
end
end
left == right
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
# return this as the result.
return context.evaluate(left) if op.nil?
left = Liquid::Utils.to_liquid_value(context.evaluate(left))
right = Liquid::Utils.to_liquid_value(context.evaluate(right))
operation = self.class.operators[op] || raise(Liquid::ArgumentError, "Unknown operator #{op}")
if operation.respond_to?(:call)
operation.call(self, left, right)
elsif left.respond_to?(operation) && right.respond_to?(operation) && !left.is_a?(Hash) && !right.is_a?(Hash)
begin
left.send(operation, right)
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message
end
end
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
@@ -163,8 +48,6 @@ module Liquid
def children
[
@node.left,
@node.right,
@node.child_condition,
@node.attachment
].compact
end
+4 -8
View File
@@ -60,10 +60,6 @@ module Liquid
end
# rubocop:enable Metrics/ParameterLists
def warnings
@warnings ||= []
end
def strainer
@strainer ||= @environment.create_strainer(self, @filters)
end
@@ -157,7 +153,6 @@ module Liquid
subcontext.filters = @filters
subcontext.strainer = nil
subcontext.errors = errors
subcontext.warnings = warnings
subcontext.disabled_tags = @disabled_tags
end
end
@@ -180,11 +175,12 @@ module Liquid
# Example:
# products == empty #=> products.empty?
def [](expression)
evaluate(Expression.parse(expression, @string_scanner))
@string_scanner.string = expression
evaluate(Parser.new(@string_scanner).expression)
end
def key?(key)
self[key] != nil
find_variable(key, raise_on_not_found: false) != nil
end
def evaluate(object)
@@ -244,7 +240,7 @@ module Liquid
protected
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags
attr_writer :base_scope_depth, :errors, :strainer, :filters, :disabled_tags
private
+1 -1
View File
@@ -31,7 +31,7 @@ module Liquid
# Catch all for the method
def liquid_method_missing(method)
return nil unless @context&.strict_variables
return unless @context&.strict_variables
raise Liquid::UndefinedDropMethod, "undefined method #{method}"
end
+1 -9
View File
@@ -4,10 +4,6 @@ module Liquid
# The Environment is the container for all configuration options of Liquid, such as
# the registered tags, filters, and the default error mode.
class Environment
# The default error mode for all templates. This can be overridden on a
# per-template basis.
attr_accessor :error_mode
# The tags that are available to use in the template.
attr_accessor :tags
@@ -33,17 +29,14 @@ module Liquid
# the template.
# @param file_system The default file system that is used
# to load templates from.
# @param error_mode [Symbol] The default error mode for all templates
# (either :strict, :warn, or :lax).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
def build(tags: nil, file_system: nil, exception_renderer: nil)
ret = new
ret.tags = tags if tags
ret.file_system = file_system if file_system
ret.error_mode = error_mode if error_mode
ret.exception_renderer = exception_renderer if exception_renderer
yield ret if block_given?
ret.freeze
@@ -75,7 +68,6 @@ module Liquid
# @api private
def initialize
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
end
+17 -57
View File
@@ -9,11 +9,8 @@ module Liquid
'' => nil,
'true' => true,
'false' => false,
'blank' => '',
'empty' => '',
# in lax mode, minus sign can be a VariableLookup
# For simplicity and performace, we treat it like a literal
'-' => VariableLookup.parse("-", nil).freeze,
'blank' => MethodLiteral::BLANK,
'empty' => MethodLiteral::EMPTY,
}.freeze
DOT = ".".ord
@@ -51,72 +48,35 @@ module Liquid
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,
if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX
start_markup = Regexp.last_match(1)
end_markup = Regexp.last_match(2)
start_obj = parse(start_markup, ss, cache)
end_obj = parse(end_markup, ss, cache)
return RangeLookup.create(
start_obj,
end_obj,
start_markup,
end_markup,
)
end
if (num = parse_number(markup, ss))
if (num = parse_number(markup))
num
else
VariableLookup.parse(markup, ss, cache)
end
end
def parse_number(markup, ss)
def parse_number(markup)
# check if the markup is simple integer or float
case markup
when INTEGER_REGEX
return Integer(markup, 10)
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
markup.to_f
else
# number ends with a dot "123."
markup.byteslice(0, first_dot_pos).to_f
false
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
+14 -8
View File
@@ -6,22 +6,24 @@ module Liquid
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
COMMA = [:comma, ","].freeze
COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze
COMPARISON_CONTAINS = [:comparison, "contains"].freeze
COMPARISON_EQUAL = [:comparison, "=="].freeze
COMPARISON_GREATER_THAN = [:comparison, ">"].freeze
COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze
COMPARISON_LESS_THAN = [:comparison, "<"].freeze
COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze
COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
EQUALITY_EQUAL_EQUAL = [:equality, "=="].freeze
EQUALITY_NOT_EQUAL = [:equality, "!="].freeze
EQUALITY_NOT_EQUAL_ALT = [:equality, "<>"].freeze
DASH = [:dash, "-"].freeze
DOT = [:dot, "."].freeze
DOTDOT = [:dotdot, ".."].freeze
DOT_ORD = ".".ord
DOUBLE_STRING_LITERAL = /"[^\"]*"/
EOS = [:end_of_string].freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
LOGICAL_AND = [:logical, 'and'].freeze
LOGICAL_OR = [:logical, 'or'].freeze
NUMBER_LITERAL = /-?\d+(\.\d+)?/
OPEN_ROUND = [:open_round, "("].freeze
OPEN_SQUARE = [:open_square, "["].freeze
PIPE = [:pipe, "|"].freeze
@@ -38,11 +40,11 @@ module Liquid
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
table["=".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_EQUAL
sub_table["=".ord] = EQUALITY_EQUAL_EQUAL
sub_table.freeze
end
table["!".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table["=".ord] = EQUALITY_NOT_EQUAL
sub_table.freeze
end
table.freeze
@@ -51,7 +53,7 @@ module Liquid
COMPARISON_JUMP_TABLE = [].tap do |table|
table["<".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
sub_table[">".ord] = EQUALITY_NOT_EQUAL_ALT
sub_table.freeze
end
table[">".ord] = [].tap do |sub_table|
@@ -151,6 +153,10 @@ module Liquid
# Special case for "contains"
output << if type == :id && t == "contains" && output.last&.first != :dot
COMPARISON_CONTAINS
elsif type == :id && t == "and" && output.last&.first != :dot
LOGICAL_AND
elsif type == :id && t == "or" && output.last&.first != :dot
LOGICAL_OR
else
[type, t]
end
+1
View File
@@ -20,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"
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
module Liquid
class MethodLiteral
attr_reader :method_name, :to_s
def initialize(method_name, to_s, &evaluator)
@method_name = method_name
@to_s = to_s
@evaluator = evaluator
end
def apply(value)
if value.respond_to?(@method_name)
value.send(@method_name)
elsif @evaluator
@evaluator.call(value)
end
end
def to_liquid
to_s
end
BLANK = MethodLiteral.new(:blank?, '') do |value|
case value
when NilClass, FalseClass
true
when TrueClass, Numeric
false
when String
value.empty? || value.match?(/\A\s*\z/)
when Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : false
end
end.freeze
EMPTY = MethodLiteral.new(:empty?, '') do |value|
case value
when String, Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : nil
end
end.freeze
end
end
+3 -10
View File
@@ -3,14 +3,13 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode, :environment
attr_reader :partial, :environment
def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new
@warnings = []
@locale = @template_options[:locale] ||= I18n.new
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
# This StringScanner will be shared by all of them
@@ -38,7 +37,7 @@ module Liquid
def new_parser(input)
@string_scanner.string = input
Parser.new(@string_scanner)
Parser.new(@string_scanner, @expression_cache)
end
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
@@ -50,15 +49,9 @@ module Liquid
)
end
def parse_expression(markup)
Expression.parse(markup, @string_scanner, @expression_cache)
end
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || @environment.error_mode
end
def partial_options
+177 -12
View File
@@ -2,9 +2,10 @@
module Liquid
class Parser
def initialize(input)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
def initialize(input, expression_cache = nil)
@ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@cache = expression_cache
@tokens = Lexer.tokenize(@ss)
@p = 0 # pointer to current location
end
@@ -12,6 +13,8 @@ module Liquid
@p = point
end
# Consumes a token of specific type.
# Throws SyntaxError if token doesn't match type expectation.
def consume(type = nil)
token = @tokens[@p]
if type && token[0] != type
@@ -40,30 +43,154 @@ module Liquid
token[1]
end
# Peeks the ahead token, returning true if matching expectation
def look(type, ahead = 0)
tok = @tokens[@p + ahead]
return false unless tok
tok[0] == type
end
# expression := logical
# logical := equality (("and" | "or") equality)*
# equality := comparison (("==" | "!=" | "<>") comparison)*
# comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)*
# primary := string | number | variable_lookup | range | boolean | grouping
def expression
logical
end
# Logical relations use right-to-left associativity.
# `a and b or c` is evaluated like (a and (b or c))
# This enables short-circuit: if `a` is false, entire expression short-circuits.
# logical := equality (("and" | "or") logical)?
def logical
left = equality
if (operator = consume?(:logical))
right = logical # recursive call builds proper RTL tree
BinaryExpression.new(left, operator, right)
else
left
end
end
# equality := comparison (("==" | "!=" | "<>") comparison)*
def equality
expr = comparison
while look(:equality)
operator = consume
expr = BinaryExpression.new(expr, operator, comparison)
end
expr
end
# comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)*
def comparison
expr = primary
while look(:comparison)
operator = consume
expr = BinaryExpression.new(expr, operator, primary)
end
expr
end
# primary := string | number | variable_lookup | range | boolean | grouping
def primary
token = @tokens[@p]
case token[0]
when :id
variable_lookup_or_literal
when :open_square
unnamed_variable_lookup
when :string
string
when :number
number
when :open_round
grouping_or_range_lookup
else
raise SyntaxError, "#{token} is not a valid expression"
end
end
def number
num = consume(:number)
Expression.parse_number(num)
end
def string
consume(:string)[1..-2]
end
# variable_lookup := id (lookup)*
# lookup := indexed_lookup | dot_lookup
# indexed_lookup := "[" expression "]"
# dot_lookup := "." id
def variable_lookup
name = consume(:id)
lookups, command_flags = variable_lookups
VariableLookup.new(name, lookups, command_flags)
end
# a variable_lookup without lookups could be a literal
def variable_lookup_or_literal
name = consume(:id)
lookups, command_flags = variable_lookups
if Expression::LITERALS.key?(name) && lookups.empty?
Expression::LITERALS[name]
else
VariableLookup.new(name, lookups, command_flags)
end
end
# unnamed_variable_lookup := indexed_lookup (lookup)*
def unnamed_variable_lookup
name = indexed_lookup
lookups, command_flags = variable_lookups
VariableLookup.new(name, lookups, command_flags)
end
# Parenthesized expressions are recursive
# grouping := "(" expression ")"
def grouping_or_range_lookup
consume(:open_round)
expr = expression
if consume?(:dotdot)
RangeLookup.create(expr, expression)
else
expr
end
ensure
consume(:close_round)
end
# range_lookup := "(" expression ".." expression ")"
def range_lookup
consume(:open_round)
first = expression
consume(:dotdot)
last = expression
consume(:close_round)
RangeLookup.create(first, last)
end
def expression_string
token = @tokens[@p]
case token[0]
when :id
str = consume
str << variable_lookups
str << variable_lookups_string
when :open_square
str = consume.dup
str << expression
str << expression_string
str << consume(:close_square)
str << variable_lookups
str << variable_lookups_string
when :string, :number
consume
when :open_round
consume
first = expression
first = expression_string
consume(:dotdot)
last = expression
last = expression_string
consume(:close_round)
"(#{first}..#{last})"
else
@@ -71,23 +198,23 @@ module Liquid
end
end
def argument
def argument_string
str = +""
# might be a keyword argument (identifier: expression)
if look(:id) && look(:colon, 1)
str << consume << consume << ' '
end
str << expression
str << expression_string
str
end
def variable_lookups
def variable_lookups_string
str = +""
loop do
if look(:open_square)
str << consume
str << expression
str << expression_string
str << consume(:close_square)
elsif look(:dot)
str << consume
@@ -98,5 +225,43 @@ module Liquid
end
str
end
# Assumes safe input. For cases where you need the string.
# Don't use this unless you're sure about what you're doing.
def unsafe_parse_expression(markup)
parse_expression(markup)
end
private
def parse_expression(markup)
Expression.parse(markup, @ss, @cache)
end
def variable_lookups
lookups = []
command_flags = 0
i = -1
loop do
i += 1
if look(:open_square)
lookups << indexed_lookup
elsif consume?(:dot)
lookup = consume(:id)
lookups << lookup
command_flags |= 1 << i if VariableLookup::COMMAND_METHODS.include?(lookup)
else
break
end
end
[lookups, command_flags]
end
def indexed_lookup
consume(:open_square)
expr = expression
consume(:close_square)
expr
end
end
end
+3 -29
View File
@@ -2,42 +2,16 @@
module Liquid
module ParserSwitching
def strict_parse_with_error_mode_fallback(markup)
strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
when :strict
raise
when :warn
parse_context.warnings << e
end
lax_parse(markup)
end
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 :warn
begin
strict_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
end
end
end
private
def strict_parse_with_error_context(markup)
strict_parse(markup)
parse_markup(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
private
def markup_context(markup)
"in \"#{markup.strip}\""
end
+1 -1
View File
@@ -4,7 +4,7 @@ module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials]
cache_key = "#{template_name}:#{parse_context.error_mode}"
cache_key = template_name.to_s
cached = cached_partials[cache_key]
return cached if cached
+3 -3
View File
@@ -2,15 +2,15 @@
module Liquid
class RangeLookup
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)
def self.create(start_obj, end_obj, start_markup = nil, end_markup = nil)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
begin
start_obj.to_i..end_obj.to_i
rescue NoMethodError
start_markup = start_obj.to_s unless start_markup
end_markup = end_obj.to_s unless end_markup
invalid_expr = start_markup unless start_obj.respond_to?(:to_i)
invalid_expr ||= end_markup unless end_obj.respond_to?(:to_i)
if invalid_expr
+4
View File
@@ -768,6 +768,8 @@ module Liquid
# @liquid_syntax array | first
# @liquid_return [untyped]
def first(array)
# ActiveSupport returns "" for empty strings, not nil
return array[0] || "" if array.is_a?(String)
array.first if array.respond_to?(:first)
end
@@ -779,6 +781,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
-6
View File
@@ -65,11 +65,5 @@ module Liquid
def blank?
false
end
private
def parse_expression(markup)
parse_context.parse_expression(markup)
end
end
end
+18 -15
View File
@@ -23,20 +23,12 @@ module Liquid
# @liquid_syntax_keyword second_expression An expression to be rendered when the variable's value matches `second_value`.
# @liquid_syntax_keyword third_expression An expression to be rendered when the variable's value has no match.
class Case < Block
Syntax = /(#{QuotedFragment})/o
WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om
attr_reader :blocks, :left
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,20 +83,31 @@ module Liquid
private
def parse_markup(markup)
parser = @parse_context.new_parser(markup)
@left = parser.expression
parser.consume(:end_of_string)
end
def record_when_condition(markup)
body = new_body
while markup
unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
end
parse_when(markup, body)
end
markup = Regexp.last_match(2)
def parse_when(markup, body)
parser = @parse_context.new_parser(markup)
block = Condition.new(@left, '==', Condition.parse_expression(parse_context, Regexp.last_match(1)))
loop do
expr = BinaryExpression.new(@left, '==', parser.equality)
block = Condition.new(expr)
block.attach(body)
@blocks << block
break unless parser.consume?(:logical) == 'or' || parser.consume?(:comma)
end
parser.consume(:end_of_string)
end
def record_else_condition(markup)
+46 -24
View File
@@ -15,25 +15,13 @@ module Liquid
# @liquid_syntax
# {% cycle string, string, ... %}
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,17 +53,51 @@ module Liquid
private
def variables_from_string(markup)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
next unless Regexp.last_match(1)
# cycle [name:] expression(, expression)*
def parse_markup(markup)
p = @parse_context.new_parser(markup)
# Expression Parser returns cached objects, and we need to dup them to
# start the cycle over for each new cycle call.
# Liquid-C does not have a cache, so we don't need to dup the object.
var = parse_expression(Regexp.last_match(1))
var.is_a?(VariableLookup) ? var.dup : var
end.compact
@variables = []
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
first_expression = p.expression
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(p.expression)
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(p.expression)
end
p.consume(:end_of_string)
unless @is_named
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
end
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
+10 -26
View File
@@ -20,13 +20,11 @@ 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
Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o
attr_reader :collection_name, :variable_name, :limit, :from
def initialize(tag_name, markup, options)
@@ -72,28 +70,13 @@ module Liquid
protected
def lax_parse(markup)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
collection_name = Regexp.last_match(2)
@reversed = !!Regexp.last_match(3)
@name = "#{@variable_name}-#{collection_name}"
@collection_name = parse_expression(collection_name)
markup.scan(TagAttributes) do |key, value|
set_attribute(key, value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.for")
end
end
def strict_parse(markup)
def parse_markup(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 = p.expression_string
@collection_name = p.unsafe_parse_expression(collection_name)
@name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed')
@@ -104,7 +87,7 @@ 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)
end
p.consume(:end_of_string)
end
@@ -174,16 +157,17 @@ module Liquid
output
end
def set_attribute(key, expr)
def set_attribute(key, p)
expr = p.expression_string
case key
when 'offset'
@from = if expr == 'continue'
:continue
else
parse_expression(expr)
p.unsafe_parse_expression(expr)
end
when 'limit'
@limit = parse_expression(expr)
@limit = p.unsafe_parse_expression(expr)
end
end
+2 -51
View File
@@ -14,10 +14,6 @@ module Liquid
# @liquid_syntax_keyword condition The condition to evaluate.
# @liquid_syntax_keyword expression The expression to render if the condition is met.
class If < Block
Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o
ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o
BOOLEAN_OPERATORS = %w(and or).freeze
attr_reader :blocks
def initialize(tag_name, markup, options)
@@ -77,58 +73,13 @@ module Liquid
block.attach(new_body)
end
def parse_expression(markup)
Condition.parse_expression(parse_context, markup)
end
def lax_parse(markup)
expressions = markup.scan(ExpressionsAndOperators)
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop =~ Syntax
condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3)))
until expressions.empty?
operator = expressions.pop.to_s.strip
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop.to_s =~ Syntax
new_condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3)))
raise SyntaxError, options[:locale].t("errors.syntax.if") unless BOOLEAN_OPERATORS.include?(operator)
new_condition.send(operator, condition)
condition = new_condition
end
condition
end
def strict_parse(markup)
def parse_markup(markup)
p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p)
condition = Condition.new(p.expression)
p.consume(:end_of_string)
condition
end
def parse_binary_comparisons(p)
condition = parse_comparison(p)
first_condition = condition
while (op = p.id?('and') || p.id?('or'))
child_condition = parse_comparison(p)
condition.send(op, child_condition)
condition = child_condition
end
first_condition
end
def parse_comparison(p)
a = parse_expression(p.expression)
if (op = p.consume?(:comparison))
b = parse_expression(p.expression)
Condition.new(a, op, b)
else
Condition.new(a)
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
@node.blocks
+21 -21
View File
@@ -20,31 +20,11 @@ module Liquid
class Include < Tag
prepend Tag::Disableable
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 +81,26 @@ module Liquid
alias_method :parse_context, :options
private :parse_context
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = p.expression
@variable_name_expr = p.expression if p.id?("for") || p.id?("with")
@alias_name = p.consume(:id) if p.id?("as")
p.consume?(:comma)
@attributes = {}
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = p.expression
p.consume?(:comma)
end
p.consume(:end_of_string)
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+28 -17
View File
@@ -27,7 +27,6 @@ module Liquid
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
class Render < Tag
FOR = 'for'
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
disable_tags "include"
@@ -35,22 +34,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 +83,33 @@ module Liquid
output
end
# render (string) (with|for expression)? (as id)? (key: value)*
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = template_name(p)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = p.expression 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] = p.expression
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def template_name(p)
p.string
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+32 -13
View File
@@ -19,27 +19,46 @@ 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
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2))
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.table_row")
parse_with_selected_parser(markup)
end
def parse_markup(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 = p.expression
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] = p.expression
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
+2 -16
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.
#
@@ -16,24 +16,11 @@ module Liquid
#
class Template
attr_accessor :root, :name
attr_reader :resource_limits, :warnings
attr_reader :resource_limits
attr_reader :profiler
class << self
# Sets how strict the parser should be.
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict will enforce correct syntax.
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
end
def error_mode
Environment.default.error_mode
end
def default_exception_renderer=(renderer)
Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=")
Environment.default.exception_renderer = renderer
@@ -222,7 +209,6 @@ module Liquid
ParseContext.new(opts)
end
@warnings = parse_context.warnings
parse_context
end
+1 -1
View File
@@ -117,7 +117,7 @@ module Liquid
byte_a = byte_b = @ss.scan_byte
while byte_b
byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY)
byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY
break unless byte_a
+3 -1
View File
@@ -69,7 +69,7 @@ module Liquid
return obj if obj.respond_to?(:strftime)
if obj.is_a?(String)
return nil if obj.empty?
return if obj.empty?
obj = obj.downcase
end
@@ -95,6 +95,8 @@ module Liquid
def self.to_s(obj, seen = {})
case obj
when BigDecimal
obj.to_s("F")
when Hash
# If the custom hash implementation overrides `#to_s`, use their
# custom implementation. Otherwise we use Liquid's default
+40 -46
View File
@@ -30,7 +30,7 @@ module Liquid
@parse_context = parse_context
@line_number = parse_context.line_number
strict_parse_with_error_mode_fallback(markup)
parse_with_selected_parser(markup)
end
def raw
@@ -41,47 +41,17 @@ module Liquid
"in \"{{#{markup}}}\""
end
def lax_parse(markup)
@filters = []
return unless markup =~ MarkupWithQuotedFragment
name_markup = Regexp.last_match(1)
filter_markup = Regexp.last_match(2)
@name = parse_context.parse_expression(name_markup)
if filter_markup =~ FilterMarkupRegex
filters = Regexp.last_match(1).scan(FilterParser)
filters.each do |f|
next unless f =~ /\w+/
filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten
@filters << parse_filter_expressions(filtername, filterargs)
end
end
end
def strict_parse(markup)
def parse_markup(markup)
@filters = []
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.parse_expression(p.expression)
while p.consume?(:pipe)
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << parse_filter_expressions(filtername, filterargs)
end
@name = p.expression
@filters << parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
def parse_filterargs(p)
# first argument
filterargs = [p.argument]
# followed by comma separated others
filterargs << p.argument while p.consume?(:comma)
filterargs
end
def render(context)
obj = context.evaluate(@name)
@@ -122,22 +92,46 @@ module Liquid
private
def parse_filter_expressions(filter_name, unparsed_args)
filter_args = []
keyword_args = nil
unparsed_args.each do |a|
if (matches = a.match(JustTagAttributes))
keyword_args ||= {}
keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
else
filter_args << parse_context.parse_expression(a)
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 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 = [filter_name, filter_args]
result << keyword_args if keyword_args
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 = p.expression
keyword_arguments[key] = value
else
positional_arguments << p.expression
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
+16 -9
View File
@@ -7,10 +7,6 @@ module Liquid
attr_reader :name, :lookups
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
new(markup, string_scanner, cache)
end
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
lookups = markup.scan(VariableParser)
name = lookups.shift
@@ -21,12 +17,10 @@ module Liquid
cache,
)
end
@name = name
@lookups = lookups
@command_flags = 0
command_flags = 0
@lookups.each_index do |i|
lookups.each_index do |i|
lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']')
lookups[i] = Expression.parse(
@@ -35,9 +29,17 @@ module Liquid
cache,
)
elsif COMMAND_METHODS.include?(lookup)
@command_flags |= 1 << i
command_flags |= 1 << i
end
end
new(name, lookups, command_flags)
end
def initialize(name, lookups, command_flags)
@name = name
@lookups = lookups
@command_flags = command_flags
end
def lookup_command?(lookup_index)
@@ -70,6 +72,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.8.7"
VERSION = "5.11.0"
end
-1
View File
@@ -4,7 +4,6 @@ require 'benchmark/ips'
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
-2
View File
@@ -53,8 +53,6 @@ class Profiler
end
end
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
runner = ThemeRunner.new
Profiler.run do |x|
x.profile('parse') { runner.compile }
-1
View File
@@ -3,7 +3,6 @@
require 'stackprof'
require_relative 'theme_runner'
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
profiler.run
+32 -21
View File
@@ -6,7 +6,7 @@ require "benchmark/ips"
require 'liquid'
RubyVM::YJIT.enable
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
STRING_MARKUPS = [
"\"foo\"",
@@ -45,22 +45,14 @@ NUMBER_MARKUPS = [
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',
@@ -75,20 +67,39 @@ MARKUPS = {
"range" => RANGE_MARKUPS,
}
Benchmark.ips do |x|
x.config(time: 5, warmup: 5)
module Liquid
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)
ss = StringScanner.new('')
MARKUPS.each do |type, markups|
x.report("#{type} - Liquid::Expression#parse") do
markups.each do |markup|
ss.string = markup
Expression.parse(markup, ss)
end
end
x.report("#{type} - Liquid::Parser#expression") do
markups.each do |markup|
ss.string = markup
Parser.new(ss).expression
end
end
x.report("#{type} - Liquid::Expression.parse(Parser#expression_string)") do
markups.each do |markup|
ss.string = markup
Expression.parse(Parser.new(ss).expression_string, ss)
end
end
end
x.report("Liquid::Expression#parse: all") do
MARKUPS.values.flatten.each do |markup|
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
+4 -3
View File
@@ -6,7 +6,7 @@ require "benchmark/ips"
require 'liquid'
RubyVM::YJIT.enable
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
EXPRESSIONS = [
"foo[1..2].baz",
@@ -31,11 +31,12 @@ EXPRESSIONS = [
Benchmark.ips do |x|
x.config(time: 10, warmup: 5)
ss = StringScanner.new('')
x.report("Liquid::Lexer#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer.new(expr)
l.tokenize
ss.string = expr
Liquid::Lexer.tokenize(ss)
end
end
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation)
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
LiquidSpec.configure do |config|
# Run core Liquid specs
config.features = [:core]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
# @param ctx [Hash] adapter context containing :template
# @param assigns [Hash] environment variables
# @param options [Hash] :registers, :strict_errors, :exception_renderer
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+37
View File
@@ -0,0 +1,37 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'active_support/all'
require 'liquid'
LiquidSpec.configure do |config|
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
config.features = [:core, :activesupport]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
# @param ctx [Hash] adapter context containing :template
# @param assigns [Hash] environment variables
# @param options [Hash] :registers, :strict_errors, :exception_renderer
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with YJIT + ActiveSupport
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
# Enable YJIT if available
if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
RubyVM::YJIT.enable
end
require 'active_support/all'
require 'liquid'
LiquidSpec.configure do |config|
config.features = [:core, :activesupport]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
ctx[:template] = Liquid::Template.parse(source, **options)
end
# Render a compiled template with the given context
LiquidSpec.render do |ctx, assigns, options|
registers = Liquid::Registers.new(options[:registers] || {})
context = Liquid::Context.build(
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
end
+15 -4
View File
@@ -35,17 +35,28 @@ class AssignTest < Minitest::Test
)
end
def test_assign_boolean_expression_assignment
assert_template_result(
'it rendered',
<<~LIQUID,
{%- assign should_render = a == 0 or (b == 1 and c == 2) -%}
{%- if should_render -%}
it rendered
{%- endif -%}
LIQUID
{ 'b' => 1, 'c' => 2 },
)
end
def test_assign_syntax_error
assert_match_syntax_error(/assign/, '{% assign foo not values %}.')
end
def test_assign_uses_error_mode
def test_assign_throws_on_unsupported_syntax
assert_match_syntax_error(
"Expected dotdot but found pipe in ",
"Expected close_round but found pipe",
"{% assign foo = ('X' | downcase) %}",
error_mode: :strict,
)
assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax)
end
def test_expression_with_whitespace_in_square_brackets
+17 -4
View File
@@ -632,13 +632,26 @@ class ContextTest < Minitest::Test
end
def test_has_key_will_not_add_an_error_for_missing_keys
with_error_mode(:strict) do
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
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,
+4 -51
View File
@@ -67,20 +67,11 @@ class ErrorHandlingTest < Minitest::Test
end
def test_unrecognized_operator
with_error_mode(:strict) do
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
end
def test_lax_unrecognized_operator
template = Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :lax)
assert_equal(' Liquid error: Unknown operator =! ', template.render)
assert_equal(1, template.errors.size)
assert_equal(Liquid::ArgumentError, template.errors.first.class)
end
def test_with_line_numbers_adds_numbers_to_parser_errors
source = <<~LIQUID
foobar
@@ -104,25 +95,6 @@ class ErrorHandlingTest < Minitest::Test
assert_match_syntax_error(/Liquid syntax error \(line 3\)/, source)
end
def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
template = Liquid::Template.parse(
'
foobar
{% if 1 =! 2 %}ok{% endif %}
bla
',
error_mode: :warn,
line_numbers: true,
)
assert_equal(
['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'],
template.warnings.map(&:message),
)
end
def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors
err = assert_raises(SyntaxError) do
Liquid::Template.parse(
@@ -133,7 +105,6 @@ class ErrorHandlingTest < Minitest::Test
bla
',
error_mode: :strict,
line_numbers: true,
)
end
@@ -157,34 +128,16 @@ class ErrorHandlingTest < Minitest::Test
def test_strict_error_messages
err = assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :strict)
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
assert_equal('Liquid syntax error: Unexpected character = in "1 =! 2"', err.message)
err = assert_raises(SyntaxError) do
Liquid::Template.parse('{{%%%}}', error_mode: :strict)
Liquid::Template.parse('{{%%%}}')
end
assert_equal('Liquid syntax error: Unexpected character % in "{{%%%}}"', err.message)
end
def test_warnings
template = Liquid::Template.parse('{% if ~~~ %}{{%%%}}{% else %}{{ hello. }}{% endif %}', error_mode: :warn)
assert_equal(3, template.warnings.size)
assert_equal('Unexpected character ~ in "~~~"', template.warnings[0].to_s(false))
assert_equal('Unexpected character % in "{{%%%}}"', template.warnings[1].to_s(false))
assert_equal('Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].to_s(false))
assert_equal('', template.render)
end
def test_warning_line_numbers
template = Liquid::Template.parse("{% if ~~~ %}\n{{%%%}}{% else %}\n{{ hello. }}{% endif %}", error_mode: :warn, line_numbers: true)
assert_equal('Liquid syntax error (line 1): Unexpected character ~ in "~~~"', template.warnings[0].message)
assert_equal('Liquid syntax error (line 2): Unexpected character % in "{{%%%}}"', template.warnings[1].message)
assert_equal('Liquid syntax error (line 3): Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].message)
assert_equal(3, template.warnings.size)
assert_equal([1, 2, 3], template.warnings.map(&:line_number))
end
# Liquid should not catch Exceptions that are not subclasses of StandardError, like Interrupt and NoMemoryError
def test_exceptions_propagate
assert_raises(Exception) do
+34 -18
View File
@@ -26,8 +26,7 @@ class ExpressionTest < Minitest::Test
def test_float
assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}")
assert_expression_result(0.0, "0.....5")
assert_expression_result(0.0, "-0..1")
assert_expression_result(1.5, "1.5")
# this is a unfortunate quirky behavior of Liquid
@@ -47,23 +46,11 @@ class ExpressionTest < Minitest::Test
"{{ (false..true) }}",
)
assert_match_syntax_error(
"Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression",
"Liquid syntax error (line 1): Invalid expression type '1..2' in range expression",
"{{ ((1..2)..3) }}",
)
end
def test_quirky_negative_sign_expression_markup
result = Expression.parse("-", nil)
assert(result.is_a?(VariableLookup))
assert_equal("-", result.name)
# for this template, the expression markup is "-"
assert_template_result(
"",
"{{ - 'theme.css' - }}",
)
end
def test_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
@@ -80,7 +67,7 @@ class ExpressionTest < Minitest::Test
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
["1", "2", "x", "y"],
[],
cache.to_a.map { _1[0] }.sort,
)
end
@@ -104,7 +91,7 @@ class ExpressionTest < Minitest::Test
cache = parse_context.instance_variable_get(:@expression_cache)
assert_equal(
["1", "2", "x", "y"],
[],
cache.to_a.map { _1[0] }.sort,
)
end
@@ -125,7 +112,7 @@ class ExpressionTest < Minitest::Test
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
["1", "2", "x", "y"],
[],
cache.to_a.map { _1[0] }.sort,
)
end
@@ -147,6 +134,35 @@ class ExpressionTest < Minitest::Test
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
end
def test_parser_expression_with_variable_lookup
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('product.title')
result = parser.expression
assert_instance_of(Liquid::VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parser_expression_with_number
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('42')
result = parser.expression
assert_equal(42, result)
end
def test_parser_expression_raises_syntax_error_for_invalid_expression
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('')
error = assert_raises(Liquid::SyntaxError) do
parser.expression
end
assert_match(/is not a valid expression/, error.message)
end
private
def assert_expression_result(expect, markup, **assigns)
+2 -8
View File
@@ -88,17 +88,11 @@ class HashRenderingTest < Minitest::Test
end
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
my_hash = Class.new(Hash) do
def to_s
"kewl"
end
end.new
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash })
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
end
def test_rendering_hash_without_custom_to_s_uses_default_inspect
my_hash = Class.new(Hash).new
my_hash = HashWithoutCustomToS.new
my_hash[:foo] = :bar
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
+11 -91
View File
@@ -31,58 +31,24 @@ class ParsingQuirksTest < Minitest::Test
def test_error_on_empty_filter
assert(Template.parse("{{test}}"))
with_error_mode(:lax) do
assert(Template.parse("{{|test}}"))
end
with_error_mode(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end
assert_raises(Liquid::SyntaxError) { Template.parse("{{|test}}") }
assert_raises(Liquid::SyntaxError) { Template.parse("{{test |a|b|}}") }
end
def test_meaningless_parens_error
with_error_mode(:strict) do
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
def test_supported_parens
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
out = Template.parse("{% if #{markup} %} YES {% endif %}").render({ 'b' => 'bar', 'c' => 'baz' })
assert_equal(' YES ', out)
end
def test_unexpected_characters_syntax_error
with_error_mode(:strict) do
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
assert_raises(SyntaxError) do
markup = "false || true"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
end
def test_no_error_on_lax_empty_filter
assert(Template.parse("{{test |a|b|}}", error_mode: :lax))
assert(Template.parse("{{test}}", error_mode: :lax))
assert(Template.parse("{{|test|}}", error_mode: :lax))
end
def test_meaningless_parens_lax
with_error_mode(: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)
end
end
def test_unexpected_characters_silently_eat_logic_lax
with_error_mode(:lax) do
assert_raises(SyntaxError) do
markup = "true && false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
Template.parse("{% if #{markup} %} YES {% endif %}")
end
assert_raises(SyntaxError) do
markup = "false || true"
assert_template_result('', "{% if #{markup} %} YES {% endif %}")
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
@@ -92,32 +58,6 @@ class ParsingQuirksTest < Minitest::Test
end
end
def test_unanchored_filter_arguments
with_error_mode(:lax) do
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
assert_template_result('x', "{{ 'X' | downcase) }}")
# After the messed up quotes a filter without parameters (reverse) should work
# but one with parameters (remove) shouldn't be detected.
assert_template_result('here', "{{ 'hi there' | split:\"t\"\" | reverse | first}}")
assert_template_result('hi ', "{{ 'hi there' | split:\"t\"\" | remove:\"i\" | first}}")
end
end
def test_invalid_variables_work
with_error_mode(: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
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end
end
def test_blank_variable_markup
assert_template_result('', "{{}}")
end
@@ -131,24 +71,4 @@ class ParsingQuirksTest < Minitest::Test
def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true })
end
def test_incomplete_expression
with_error_mode(:lax) do
assert_template_result("false", "{{ false - }}")
assert_template_result("false", "{{ false > }}")
assert_template_result("false", "{{ false < }}")
assert_template_result("false", "{{ false = }}")
assert_template_result("false", "{{ false ! }}")
assert_template_result("false", "{{ false 1 }}")
assert_template_result("false", "{{ false a }}")
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false =\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false !\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false 1\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}")
end
end
end # ParsingQuirksTest
+2 -2
View File
@@ -59,7 +59,7 @@ class SecurityTest < Minitest::Test
GC.start
assert_equal([], (Symbol.all_symbols - current_symbols))
assert_equal([], Symbol.all_symbols - current_symbols)
end
def test_does_not_add_drop_methods_to_symbol_table
@@ -70,7 +70,7 @@ class SecurityTest < Minitest::Test
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))
assert_equal([], Symbol.all_symbols - current_symbols)
end
def test_max_depth_nested_blocks_does_not_raise_exception
+37 -9
View File
@@ -116,7 +116,7 @@ class StandardFiltersTest < Minitest::Test
end
def test_slice_on_arrays
input = 'foobar'.split(//)
input = 'foobar'.split('')
assert_equal(%w(o o b), @filters.slice(input, 1, 3))
assert_equal(%w(o o b a r), @filters.slice(input, 1, 1000))
assert_equal(%w(), @filters.slice(input, 1, 0))
@@ -294,13 +294,7 @@ class StandardFiltersTest < Minitest::Test
end
def test_join_calls_to_liquid_on_each_element
drop = Class.new(Liquid::Drop) do
def to_liquid
'i did it'
end
end
assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", "))
assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
end
def test_sort
@@ -633,6 +627,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))
@@ -1302,7 +1330,7 @@ class StandardFiltersTest < Minitest::Test
assert_equal(1, @filters.sum(input, true))
assert_equal(0.2, @filters.sum(input, 1.0))
assert_equal(-0.3, @filters.sum(input, 1))
assert_equal(0.4, @filters.sum(input, (1..5)))
assert_equal(0.4, @filters.sum(input, 1..5))
assert_equal(0, @filters.sum(input, nil))
assert_equal(0, @filters.sum(input, ""))
end
+121 -16
View File
@@ -3,20 +3,10 @@
require 'test_helper'
class CycleTagTest < Minitest::Test
def test_simple_cycle
template = <<~LIQUID
{%- cycle '1', '2', '3' -%}
{%- cycle '1', '2', '3' -%}
{%- cycle '1', '2', '3' -%}
LIQUID
assert_template_result("123", template)
end
def test_simple_cycle_inside_for_loop
template = <<~LIQUID
{%- for i in (1..3) -%}
{% cycle '1', '2', '3' %}
{%- cycle '1', '2', '3' -%}
{%- endfor -%}
LIQUID
@@ -36,13 +26,128 @@ class CycleTagTest < Minitest::Test
assert_template_result("123", template)
end
def test_cycle_tag_always_resets_cycle
def test_cycle_named_groups_string
template = <<~LIQUID
{%- assign a = "1" -%}
{%- cycle a, "2" -%}
{%- cycle a, "2" -%}
{%- for i in (1..3) -%}
{%- cycle 'placeholder1': 1, 2, 3 -%}
{%- cycle 'placeholder2': 1, 2, 3 -%}
{%- endfor -%}
LIQUID
assert_template_result("11", template)
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_unsupported_legacy_quirk
# QuotedFragment is more permissive than what Parser#expression allows.
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
template2 = "{% cycle .5: 'a', 'b' %}"
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
end
def test_cycle_with_trailing_elements_legacy_syntax
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' %}"
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
def test_cycle_name_with_invalid_expression
template = <<~LIQUID
{% for i in (1..3) %}
{% cycle foo=>bar: "a", "b" %}
{% endfor %}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_cycle_variable_with_invalid_expression
template = <<~LIQUID
{% for i in (1..3) %}
{% cycle foo=>bar, "a", "b" %}
{% endfor %}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
-22
View File
@@ -147,28 +147,6 @@ class IfElseTagTest < Minitest::Test
assert_raises(SyntaxError) { assert_template_result('', '{% if %}') }
end
def test_if_with_custom_condition
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
assert_template_result('yes', %({% if 'bob' contains 'o' %}yes{% endif %}))
assert_template_result('no', %({% if 'bob' contains 'f' %}yes{% else %}no{% endif %}))
ensure
Condition.operators['contains'] = original_op
end
def test_operators_are_ignored_unless_isolated
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
assert_template_result(
'yes',
%({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}),
)
ensure
Condition.operators['contains'] = original_op
end
def test_operators_are_whitelisted
assert_raises(SyntaxError) do
assert_template_result('', %({% if 1 or throw or or 1 %}yes{% endif %}))
+40 -9
View File
@@ -204,6 +204,22 @@ class IncludeTagTest < Minitest::Test
)
end
def test_parsing_errors_for_legacy_quirk
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% include "snippet" | filter %}',
)
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)
@@ -275,16 +291,10 @@ class IncludeTagTest < Minitest::Test
env = Liquid::Environment.build(file_system: TestFileSystem.new)
assert_raises(Liquid::SyntaxError) do
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, environment: env).render!("template" => '{{ "X" || downcase }}'))
Template.parse("{% include template %}", environment: env).render!("template" => '{{ "X" || downcase }}')
end
assert_raises(Liquid::SyntaxError) do
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], environment: env).render!("template" => '{{ "X" || downcase }}'))
Template.parse("{% include template %}", include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end
end
@@ -339,7 +349,7 @@ class IncludeTagTest < Minitest::Test
file_system: StubFileSystem.new('simple' => 'simple'),
)
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template = Liquid::Template.parse("{% include 'simple' %}", environment: env)
template.render(nil, strict_variables: true)
assert_equal([], template.errors)
@@ -374,4 +384,25 @@ class IncludeTagTest < Minitest::Test
render_errors: true,
)
end
def test_include_template_with_invalid_expression
template = "{% include foo=>bar %}"
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_include_with_invalid_expression
template = '{% include "snippet" with foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_include_attribute_with_invalid_expression
template = '{% include "snippet", key: foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end # IncludeTagTest
+29 -1
View File
@@ -105,7 +105,23 @@ 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_parsing_errors_legacy_syntax
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% render "snippet" | filter %}',
)
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',
@@ -288,4 +304,16 @@ class RenderTagTest < Minitest::Test
render_errors: true,
)
end
def test_render_with_invalid_expression
template = '{% render "snippet" with foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_render_attribute_with_invalid_expression
template = '{% render "snippet", key: foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
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
+163 -23
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 }}
@@ -188,35 +188,15 @@ class TableRowTest < Minitest::Test
assert_template_result(expected_output, template)
end
def test_table_row_renders_correct_error_message_for_invalid_parameters
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
render_errors: true,
)
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 +235,164 @@ class TableRowTest < Minitest::Test
template,
)
end
def test_tablerow_with_cols_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_limit_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_offset_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_range_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_multiple_attributes
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
assert_template_result(expected, template)
end
def test_tablerow_with_variable_collection
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
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
def test_tablerow_with_dotted_access
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
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
end
def test_tablerow_with_bracketed_access
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
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
def test_tablerow_without_attributes
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
assert_template_result(expected, template)
end
def test_tablerow_without_in_keyword
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
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
def test_tablerow_with_multiple_invalid_attributes_reports_first
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
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
def test_tablerow_with_empty_collection
template = <<~LIQUID.chomp
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
</tr>
OUTPUT
assert_template_result(expected, template, { 'empty_array' => [] })
end
def test_tablerow_with_invalid_attribute
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
end
def test_tablerow_with_invalid_expression
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
+2 -12
View File
@@ -44,16 +44,6 @@ class TemplateTest < Minitest::Test
assert_equal('from instance assigns', t.parse("{{ foo }}").render!)
end
def test_warnings_is_not_exponential_time
str = "false"
100.times do
str = "{% if true %}true{% else %}#{str}{% endif %}"
end
t = Template.parse(str)
assert_equal([], Timeout.timeout(1) { t.warnings })
end
def test_instance_assigns_persist_on_same_template_parsing_between_renders
t = Template.new.parse("{{ foo }}{% assign foo = 'foo' %}{{ foo }}")
assert_equal('foo', t.render!)
@@ -133,7 +123,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
@@ -259,7 +249,7 @@ class TemplateTest < Minitest::Test
end
def test_nil_value_does_not_raise
t = Template.parse("some{{x}}thing", error_mode: :strict)
t = Template.parse("some{{x}}thing")
result = t.render!({ 'x' => nil }, strict_variables: true)
assert_equal(0, t.errors.count)
+43 -29
View File
@@ -11,6 +11,24 @@ class VariableTest < Minitest::Test
assert_template_result('worked wonderfully', "{{test}}", { 'test' => 'worked wonderfully' })
end
def test_equality
assert_template_result('true', "{{ 5 == 5 }}")
assert_template_result('false', "{{ 5 == 3 }}")
end
def test_comparison
assert_template_result('true', "{{ 5 > 3 }}")
assert_template_result('false', "{{ 5 < 3 }}")
end
def test_expression_piped_into_filter
assert_template_result('TRUE', "{{ 5 == 5 | upcase }}")
end
def test_expression_used_as_filter_argument
assert_template_result('A: TRUE', "{{ 'a: $a' | replace: '$a', 5 == 5 | upcase }}")
end
def test_variable_render_calls_to_liquid
assert_template_result('foobar', '{{ foo }}', { 'foo' => ThingWithToLiquid.new })
end
@@ -176,37 +194,33 @@ class VariableTest < Minitest::Test
)
end
def test_variable_lookup_should_not_hang_with_invalid_syntax
Timeout.timeout(1) do
assert_template_result(
'bar',
"{{['foo'}}",
{
'foo' => 'bar',
},
error_mode: :lax,
)
end
def test_filter_with_single_trailing_comma
template = '{{ "hello" | append: "world", }}'
very_long_key = "1234567890" * 100
assert_template_result('helloworld', template)
end
template_list = [
"{{['#{very_long_key}']}}", # valid
"{{['#{very_long_key}'}}", # missing closing bracket
"{{[['#{very_long_key}']}}", # extra open bracket
]
def test_multiple_filters_with_trailing_commas
template = '{{ "hello" | append: "1", | append: "2", }}'
template_list.each do |template|
Timeout.timeout(1) do
assert_template_result(
'bar',
template,
{
very_long_key => 'bar',
},
error_mode: :lax,
)
end
end
assert_template_result('hello12', template)
end
def test_filter_with_colon_but_no_arguments
template = '{{ "test" | upcase: }}'
assert_template_result('TEST', template)
end
def test_filter_chain_with_colon_no_args
template = '{{ "test" | append: "x" | upcase: }}'
assert_template_result('TESTX', template)
end
def test_combining_trailing_comma_and_empty_args
template = '{{ "test" | append: "x", | upcase: }}'
assert_template_result('TESTX', template)
end
end
+26 -21
View File
@@ -8,13 +8,6 @@ $LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib'))
require 'liquid.rb'
require 'liquid/profiler'
mode = :strict
if (env_mode = ENV['LIQUID_PARSER_MODE'])
puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym
end
Liquid::Environment.default.error_mode = mode
if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here.
else
@@ -34,27 +27,27 @@ module Minitest
def assert_template_result(
expected, template, assigns = {},
message: nil, partials: nil, error_mode: nil, render_errors: false,
message: nil, partials: nil, render_errors: false,
template_factory: nil
)
file_system = StubFileSystem.new(partials || {})
environment = Liquid::Environment.build(file_system: file_system)
template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment)
template = Liquid::Template.parse(template, line_numbers: true, environment: environment)
registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory)
context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment)
output = template.render(context)
assert_equal(expected, output, message)
end
def assert_match_syntax_error(match, template, error_mode: nil)
def assert_match_syntax_error(match, template)
exception = assert_raises(Liquid::SyntaxError) do
Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym).render
Template.parse(template, line_numbers: true).render
end
assert_match(match, exception.message)
end
def assert_syntax_error(template, error_mode: nil)
assert_match_syntax_error("", template, error_mode: error_mode)
def assert_syntax_error(template)
assert_match_syntax_error("", template)
end
def assert_usage_increment(name, times: 1)
@@ -82,14 +75,6 @@ module Minitest
Environment.dangerously_override(environment, &blk)
end
def with_error_mode(mode)
old_mode = Liquid::Environment.default.error_mode
Liquid::Environment.default.error_mode = mode
yield
ensure
Liquid::Environment.default.error_mode = old_mode
end
def with_custom_tag(tag_name, tag_class, &block)
environment = Liquid::Environment.default.dup
environment.register_tag(tag_name, tag_class)
@@ -197,6 +182,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
+146
View File
@@ -0,0 +1,146 @@
# frozen_string_literal: true
require 'test_helper'
class ExecutionSpy
attr_reader :called
attr_accessor :value
def initialize(value)
@called = false
@value = value
end
def to_liquid_value
@called = true
@value
end
def reset
@called = false
end
end
class BinaryExpressionTest < Minitest::Test
include Liquid
def test_simple_comparison_evaluation
assert_eval(false, BinaryExpression.new(5, ">", 5))
assert_eval(true, BinaryExpression.new(5, ">=", 5))
assert_eval(false, BinaryExpression.new(5, "<", 5))
assert_eval(true, BinaryExpression.new(5, "<=", 5))
assert_eval(true, BinaryExpression.new("abcd", "contains", "a"))
end
def test_logical_expression_short_circuiting
spy = ExecutionSpy.new(true)
# false or spy should try spy
assert_eval(true, BinaryExpression.new(false, 'or', spy))
assert_equal(true, spy.called)
spy.reset
# true or spy should not call spy
assert_eval(true, BinaryExpression.new(true, 'or', spy))
assert_equal(false, spy.called)
spy.reset
# true and spy should try spy
assert_eval(true, BinaryExpression.new(true, 'and', spy))
assert_equal(true, spy.called)
spy.reset
# false and spy should not try spy
assert_eval(false, BinaryExpression.new(false, 'and', spy))
assert_equal(false, spy.called)
end
def test_complex_evaluation
# 1 > 2 == 2 > 3
assert_eval(true, BinaryExpression.new(
BinaryExpression.new(1, '>', 2),
'==',
BinaryExpression.new(2, '>', 3),
))
# 1 > 2 != 2 > 3
assert_eval(false, BinaryExpression.new(
BinaryExpression.new(1, '>', 2),
'!=',
BinaryExpression.new(2, '>', 3),
))
# a > 0 == b.prop > 0
assert_eval(
true,
BinaryExpression.new(
BinaryExpression.new(var('a'), '>', 0),
'==',
BinaryExpression.new(var('b.prop'), '>', 0),
),
{ 'a' => 1, 'b' => { 'prop' => 2 } },
)
end
def test_method_literal_equality
empty = MethodLiteral.new(:empty?, '')
# a == empty, empty == a
assert_eval(false, BinaryExpression.new("123", "==", empty))
assert_eval(true, BinaryExpression.new("", "==", empty))
assert_eval(false, BinaryExpression.new(empty, "==", "123"))
assert_eval(true, BinaryExpression.new(empty, "==", ""))
# a does not have .empty?
assert_eval(nil, BinaryExpression.new(1, "==", empty))
assert_eval(nil, BinaryExpression.new(true, "==", empty))
assert_eval(nil, BinaryExpression.new(false, "==", empty))
assert_eval(nil, BinaryExpression.new(nil, "==", empty))
# a != empty
assert_eval(true, BinaryExpression.new("123", "!=", empty))
assert_eval(false, BinaryExpression.new("", "!=", empty))
assert_eval(true, BinaryExpression.new(empty, "!=", "123"))
assert_eval(false, BinaryExpression.new(empty, "!=", ""))
# a does not have .empty?
assert_eval(true, BinaryExpression.new(1, "!=", empty))
assert_eval(true, BinaryExpression.new(true, "!=", empty))
assert_eval(true, BinaryExpression.new(false, "!=", empty))
assert_eval(true, BinaryExpression.new(nil, "!=", empty))
end
def test_method_literal_comparison
empty = MethodLiteral.new(:empty?, '')
['>', '>='].each do |op|
assert_eval(nil, BinaryExpression.new("123", op, empty))
assert_eval(nil, BinaryExpression.new("", op, empty))
assert_eval(nil, BinaryExpression.new(empty, op, "123"))
assert_eval(nil, BinaryExpression.new(empty, op, ""))
end
# Interesting case, contains on strings does include?(right.to_s)
assert_eval(true, BinaryExpression.new("123", "contains", empty))
assert_eval(true, BinaryExpression.new("", "contains", empty))
end
def assert_eval(expected, expr, assigns = {})
actual = expr.evaluate(context(assigns))
message = "Expected '#{expr}' to evaluate to '#{expected}'"
return assert_nil(actual, message) if expected.nil?
assert_equal(expected, actual, message)
end
def var(markup)
Parser.new(markup).variable_lookup
end
def context(assigns = {})
Context.build(outer_scope: assigns)
end
end
+225 -45
View File
@@ -9,11 +9,6 @@ class ConditionUnitTest < Minitest::Test
@context = Liquid::Context.new
end
def test_basic_condition
assert_equal(false, Condition.new(1, '==', 2).evaluate(Context.new))
assert_equal(true, Condition.new(1, '==', 1).evaluate(Context.new))
end
def test_default_operators_evalute_true
assert_evaluates_true(1, '==', 1)
assert_evaluates_true(1, '!=', 2)
@@ -72,17 +67,17 @@ class ConditionUnitTest < Minitest::Test
end
def test_hash_compare_backwards_compatibility
assert_nil(Condition.new({}, '>', 2).evaluate(Context.new))
assert_nil(Condition.new(2, '>', {}).evaluate(Context.new))
assert_equal(false, Condition.new({}, '==', 2).evaluate(Context.new))
assert_equal(true, Condition.new({ 'a' => 1 }, '==', 'a' => 1).evaluate(Context.new))
assert_equal(true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate(Context.new))
assert_evaluates_nil({}, '>', 2)
assert_evaluates_nil(2, '>', {})
assert_evaluates_false({}, '==', 2)
assert_evaluates_true({ 'a' => 1 }, '==', 'a' => 1)
assert_evaluates_true({ 'a' => 2 }, 'contains', 'a')
end
def test_contains_works_on_arrays
@context = Liquid::Context.new
@context['array'] = [1, 2, 3, 4, 5]
array_expr = VariableLookup.new("array")
array_expr = VariableLookup.parse("array")
assert_evaluates_false(array_expr, 'contains', 0)
assert_evaluates_true(array_expr, 'contains', 1)
@@ -96,8 +91,8 @@ class ConditionUnitTest < Minitest::Test
def test_contains_returns_false_for_nil_operands
@context = Liquid::Context.new
assert_evaluates_false(VariableLookup.new('not_assigned'), 'contains', '0')
assert_evaluates_false(0, 'contains', VariableLookup.new('not_assigned'))
assert_evaluates_false(VariableLookup.parse('not_assigned'), 'contains', '0')
assert_evaluates_false(0, 'contains', VariableLookup.parse('not_assigned'))
end
def test_contains_return_false_on_wrong_data_type
@@ -110,81 +105,266 @@ class ConditionUnitTest < Minitest::Test
end
def test_or_condition
condition = Condition.new(1, '==', 2)
false_expr = '1 == 2'
true_expr = '1 == 1'
condition = Condition.new(expression(false_expr))
assert_equal(false, condition.evaluate(Context.new))
condition.or(Condition.new(2, '==', 1))
condition = Condition.new(expression("#{false_expr} or #{false_expr}"))
assert_equal(false, condition.evaluate(Context.new))
condition.or(Condition.new(1, '==', 1))
condition = Condition.new(expression("#{false_expr} or #{true_expr}"))
assert_equal(true, condition.evaluate(Context.new))
condition = Condition.new(expression("#{true_expr} or #{false_expr}"))
assert_equal(true, condition.evaluate(Context.new))
end
def test_and_condition
condition = Condition.new(1, '==', 1)
false_expr = '1 == 2'
true_expr = '1 == 1'
condition = Condition.new(expression(true_expr))
assert_equal(true, condition.evaluate(Context.new))
condition.and(Condition.new(2, '==', 2))
assert_equal(true, condition.evaluate(Context.new))
condition.and(Condition.new(2, '==', 1))
condition = Condition.new(expression("#{true_expr} and #{false_expr}"))
assert_equal(false, condition.evaluate(Context.new))
end
def test_should_allow_custom_proc_operator
Condition.operators['starts_with'] = proc { |_cond, left, right| left =~ /^#{right}/ }
condition = Condition.new(expression("#{false_expr} and #{true_expr}"))
assert_equal(false, condition.evaluate(Context.new))
assert_evaluates_true('bob', 'starts_with', 'b')
assert_evaluates_false('bob', 'starts_with', 'o')
ensure
Condition.operators.delete('starts_with')
condition = Condition.new(expression("#{true_expr} and #{true_expr}"))
assert_equal(true, condition.evaluate(Context.new))
end
def test_left_or_right_may_contain_operators
@context = Liquid::Context.new
@context['one'] = @context['another'] = "gnomeslab-and-or-liquid"
assert_evaluates_true(VariableLookup.new("one"), '==', VariableLookup.new("another"))
assert_evaluates_true(VariableLookup.parse("one"), '==', VariableLookup.parse("another"))
end
def test_default_context_is_deprecated
if Gem::Version.new(Liquid::VERSION) >= Gem::Version.new('6.0.0')
flunk("Condition#evaluate without a context argument is to be removed")
end
def test_parse_expression
environment = Environment.build
parse_context = ParseContext.new(environment: environment)
parser = parse_context.new_parser('product.title')
result = parser.expression
_out, err = capture_io do
assert_equal(true, Condition.new(1, '==', 1).evaluate)
end
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
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)
def test_parser_expression_returns_method_literal_for_blank_and_empty
environment = Environment.build
parse_context = ParseContext.new(environment: environment)
parser = parse_context.new_parser('blank')
result = parser.expression
assert_instance_of(MethodLiteral, result)
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 = Expression::LITERALS['blank']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_false(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_false(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_false(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_false(VariableLookup.parse('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 = Expression::LITERALS['blank']
assert_evaluates_false(VariableLookup.parse('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 = Expression::LITERALS['empty']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['empty']
assert_evaluates_false(VariableLookup.parse('whitespace'), '==', empty_literal)
end
def test_empty_with_empty_array
# An array with no elements is empty.
# [].empty? => true
@context['empty_array'] = []
empty_literal = Expression::LITERALS['empty']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['empty']
assert_evaluates_true(VariableLookup.parse('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 = Expression::LITERALS['empty']
assert_evaluates_false(VariableLookup.parse('nil_value'), '==', empty_literal)
end
private
def assert_evaluates_nil(left, op, right)
expr = BinaryExpression.new(left, op, right)
assert_nil(
Condition.new(expr).evaluate(@context),
"Evaluated not nil: #{left.inspect} #{op} #{right.inspect}",
)
end
def assert_evaluates_true(left, op, right)
expr = BinaryExpression.new(left, op, right)
assert(
Condition.new(left, op, right).evaluate(@context),
Condition.new(expr).evaluate(@context),
"Evaluated false: #{left.inspect} #{op} #{right.inspect}",
)
end
def assert_evaluates_false(left, op, right)
expr = BinaryExpression.new(left, op, right)
assert(
!Condition.new(left, op, right).evaluate(@context),
!Condition.new(expr).evaluate(@context),
"Evaluated true: #{left.inspect} #{op} #{right.inspect}",
)
end
def assert_evaluates_argument_error(left, op, right)
assert_raises(Liquid::ArgumentError) do
Condition.new(left, op, right).evaluate(@context)
expr = BinaryExpression.new(left, op, right)
Condition.new(expr).evaluate(@context)
end
end
def expression(markup)
Parser.new(markup).expression
end
end # ConditionTest
+10 -3
View File
@@ -26,10 +26,17 @@ class LexerUnitTest < Minitest::Test
)
end
def test_equality
assert_equal(
[[:equality, '=='], [:equality, '<>'], [:equality, '!='], [:end_of_string]],
tokenize('== <> != '),
)
end
def test_comparison
assert_equal(
[[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
tokenize('== <> contains '),
[[:comparison, '>'], [:comparison, '>='], [:comparison, '<'], [:comparison, '<='], [:comparison, 'contains'], [:end_of_string]],
tokenize('> >= < <= contains'),
)
end
@@ -81,7 +88,7 @@ class LexerUnitTest < Minitest::Test
def test_whitespace
assert_equal(
[[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]],
[[:id, 'five'], [:pipe, '|'], [:equality, '=='], [:end_of_string]],
tokenize("five|\n\t =="),
)
end
+60
View File
@@ -0,0 +1,60 @@
# frozen_string_literal: true
require 'test_helper'
class ParseContextUnitTest < Minitest::Test
include Liquid
def test_parser_expression_with_variable_lookup
parser = parse_context.new_parser('product.title')
result = parser.expression
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parser_expression_raises_syntax_error_for_invalid_expression
parser = parse_context.new_parser('')
error = assert_raises(Liquid::SyntaxError) do
parser.expression
end
assert_match(/is not a valid expression/, error.message)
end
def test_parse_expression_with_variable_lookup
result = parse_context.new_parser('product.title').expression
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parser_expression_advances_parser_pointer
parser = parse_context.new_parser('foo, bar')
# parser.expression consumes "foo"
first_result = parser.expression
assert_instance_of(VariableLookup, first_result)
assert_equal('foo', first_result.name)
parser.consume(:comma)
# parser.expression consumes "bar"
second_result = parser.expression
assert_instance_of(VariableLookup, second_result)
assert_equal('bar', second_result.name)
parser.consume(:end_of_string)
end
private
def parse_context
@parse_context ||= ParseContext.new(
environment: Environment.build,
)
end
end
+169 -16
View File
@@ -45,40 +45,193 @@ class ParserUnitTest < Minitest::Test
assert_equal(false, p.look(:number, 1))
end
def test_expressions
def test_expression_string
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)
assert_equal('hi.there', p.expression_string)
assert_equal('hi?[5].there?', p.expression_string)
assert_equal('hi.there.bob', p.expression_string)
p = new_parser("567 6.0 'lol' \"wut\"")
assert_equal('567', p.expression)
assert_equal('6.0', p.expression)
assert_equal("'lol'", p.expression)
assert_equal('"wut"', p.expression)
assert_equal('567', p.expression_string)
assert_equal('6.0', p.expression_string)
assert_equal("'lol'", p.expression_string)
assert_equal('"wut"', p.expression_string)
end
def test_expression
p = new_parser("hi.there hi?[5].there? hi.there.bob")
v1 = p.expression
v2 = p.expression
v3 = p.expression
assert(v1.is_a?(VariableLookup) && v1.name == 'hi' && v1.lookups[0] == 'there')
assert(v2.is_a?(VariableLookup) && v2.name == 'hi?' && v2.lookups[0] == 5)
assert(v3.is_a?(VariableLookup) && v3.name == 'hi' && v3.lookups[0] == 'there')
p = new_parser("567 6.0 'lol' \"wut\" true false (0..5)")
assert_equal(567, p.expression)
assert_equal(6.0, p.expression)
assert_equal('lol', p.expression)
assert_equal('wut', p.expression)
assert_equal(true, p.expression)
assert_equal(false, p.expression)
assert_equal(0..5, p.expression)
end
def test_logical
p = new_parser("a and b")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('and', expr.operator)
assert_equal('a', expr.left_node.name)
assert_equal('b', expr.right_node.name)
p = new_parser("a and b or c")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('and', expr.operator)
assert_equal('a', expr.left_node.name)
assert_equal('or', expr.right_node.operator)
assert_equal('b', expr.right_node.left_node.name)
assert_equal('c', expr.right_node.right_node.name)
p = new_parser("a == b and c or d")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('and', expr.operator)
assert_equal('==', expr.left_node.operator)
assert_equal('a', expr.left_node.left_node.name)
assert_equal('b', expr.left_node.right_node.name)
assert_equal('or', expr.right_node.operator)
assert_equal('c', expr.right_node.left_node.name)
assert_equal('d', expr.right_node.right_node.name)
end
def test_equality
p = new_parser("a == b")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('==', expr.operator)
assert_equal('a', expr.left_node.name)
assert_equal('b', expr.right_node.name)
# BinaryExpression(==)
# left_node: BinaryExpression(<)
# left_node: 0
# right_node: 5
# right_node: BinaryExpression(>)
# left_node: 6
# right_node: 1
p = new_parser("0 < 5 == 6 > 1")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('==', expr.operator)
assert_equal(0, expr.left_node.left_node)
assert_equal(5, expr.left_node.right_node)
assert_equal(6, expr.right_node.left_node)
assert_equal(1, expr.right_node.right_node)
end
def test_comparison
p = new_parser("a > b")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('>', expr.operator)
assert(expr.left_node.is_a?(VariableLookup))
assert_equal('a', expr.left_node.name)
assert(expr.right_node.is_a?(VariableLookup))
assert_equal('b', expr.right_node.name)
# BinaryExpression(>=)
# left_node: BinaryExpression(>)
# left_node: 10
# right_node: 5
# right_node: 4
p = new_parser("10 > 5 >= 4")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('>=', expr.operator)
assert_equal(10, expr.left_node.left_node)
assert_equal(5, expr.left_node.right_node)
assert_equal(4, expr.right_node)
end
def test_number
p = new_parser('-1 0 1 2.0')
assert_equal(-1, p.number)
assert_equal(0, p.number)
assert_equal(1, p.number)
assert_equal(2.0, p.number)
end
def test_string
p = new_parser("'s1' \"s2\" 'this \"s3\"' \"that 's4'\"")
assert_equal('s1', p.string)
assert_equal('s2', p.string)
assert_equal('this "s3"', p.string)
assert_equal("that 's4'", p.string)
end
def test_unnamed_variable_lookup
p = new_parser('[key].title')
v = p.expression
assert(v.is_a?(VariableLookup))
assert(v.name.is_a?(VariableLookup))
assert_equal('key', v.name.name)
assert_equal('title', v.lookups[0])
end
def test_range_lookup
p = new_parser('(0..5) (a..b)')
assert_equal(0..5, p.expression)
r2 = p.expression
assert(r2.is_a?(RangeLookup))
assert_equal(1..4, r2.evaluate(Context.new({ 'a' => 1, 'b' => 4 })))
end
def test_ranges
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)
assert_equal('(hi[5].wat..old)', p.expression)
assert_equal('(5..7)', p.expression_string)
assert_equal('(1.5..9.6)', p.expression_string)
assert_equal('(young..old)', p.expression_string)
assert_equal('(hi[5].wat..old)', p.expression_string)
end
def test_arguments
def test_groupings_aka_parenthesized_expressions
# without the parens, this would be evaled as a and (b or c)
p = new_parser("(a and b) or c")
expr = p.expression
assert_equal('or', expr.operator)
assert_equal('and', expr.left_node.operator)
assert_equal('a', expr.left_node.left_node.name)
assert_equal('b', expr.left_node.right_node.name)
assert_equal('c', expr.right_node.name)
end
def test_groupings_can_be_used_to_hijack_operation_priority
# without parens would be parsed as `a and (b == c)`
p = new_parser("(a and b) == c")
expr = p.expression
assert_equal('==', expr.operator)
assert_equal('and', expr.left_node.operator)
assert_equal('a', expr.left_node.left_node.name)
assert_equal('b', expr.left_node.right_node.name)
assert_equal('c', expr.right_node.name)
end
def test_argument_string
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)
assert_equal('hi.there[5]', p.argument_string)
assert_equal(',', p.consume(:comma))
assert_equal('keyarg: 7', p.argument)
assert_equal('keyarg: 7', p.argument_string)
end
def test_invalid_expression
assert_raises(SyntaxError) do
p = new_parser("==")
p.expression
p.expression_string
end
end
+7 -9
View File
@@ -175,7 +175,7 @@ class PartialCacheUnitTest < Minitest::Test
assert_equal('some/path/my_partial', partial.name)
end
def test_includes_error_mode_into_template_cache
def test_cache_key
template_factory = StubTemplateFactory.new
context = Liquid::Context.build(
registers: {
@@ -184,16 +184,14 @@ class PartialCacheUnitTest < Minitest::Test
},
)
[:lax, :warn, :strict].each do |error_mode|
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new(error_mode: error_mode),
)
end
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new,
)
assert_equal(
["my_partial:lax", "my_partial:warn", "my_partial:strict"],
["my_partial"],
context.registers[:cached_partials].keys,
)
end
+1 -1
View File
@@ -8,7 +8,7 @@ class StrainerTemplateUnitTest < Minitest::Test
def test_add_filter_when_wrong_filter_class
c = Context.new
s = c.strainer
wrong_filter = ->(v) { v.reverse }
wrong_filter = lambda(&:reverse)
exception = assert_raises(TypeError) do
s.class.add_filter(wrong_filter)
+106
View File
@@ -9,4 +9,110 @@ 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
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
def test_case_when_with_trailing_element
template = <<~LIQUID
{%- case 1 -%}
{%- when 1 bar -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
def test_case_when_with_comma
template = <<~LIQUID
{%- case 1 -%}
{%- when 2, 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assert_template_result("one", template)
end
def test_case_when_with_or
template = <<~LIQUID
{%- case 1 -%}
{%- when 2 or 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assert_template_result("one", template)
end
def test_case_when_empty
template = <<~LIQUID
{%- case x -%}
{%- when 2 or empty -%}
2 or empty
{%- else -%}
not 2 or empty
{%- endcase -%}
LIQUID
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
def test_case_with_invalid_expression
template = <<~LIQUID
{%- case foo=>bar -%}
{%- when 'baz' -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
assert_match(/Unexpected character =/, error.message)
end
def test_case_when_with_invalid_expression
template = <<~LIQUID
{%- case 'baz' -%}
{%- when foo=>bar -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
assert_match(/Unexpected character =/, error.message)
end
end
+48 -36
View File
@@ -7,20 +7,20 @@ class VariableUnitTest < Minitest::Test
def test_variable
var = create_variable('hello')
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
end
def test_filters
var = create_variable('hello | textileze')
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['textileze', []]], var.filters)
var = create_variable('hello | textileze | paragraph')
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['textileze', []], ['paragraph', []]], var.filters)
var = create_variable(%( hello | strftime: '%Y'))
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['strftime', ['%Y']]], var.filters)
var = create_variable(%( 'typo' | link_to: 'Typo', true ))
@@ -44,11 +44,11 @@ class VariableUnitTest < Minitest::Test
assert_equal([['repeat', [3, 3, 3]]], var.filters)
var = create_variable(%( hello | strftime: '%Y, okay?'))
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['strftime', ['%Y, okay?']]], var.filters)
var = create_variable(%( hello | things: "%Y, okay?", 'the other one'))
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['things', ['%Y, okay?', 'the other one']]], var.filters)
end
@@ -60,24 +60,18 @@ class VariableUnitTest < Minitest::Test
def test_filters_without_whitespace
var = create_variable('hello | textileze | paragraph')
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['textileze', []], ['paragraph', []]], var.filters)
var = create_variable('hello|textileze|paragraph')
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['textileze', []], ['paragraph', []]], var.filters)
var = create_variable("hello|replace:'foo','bar'|textileze")
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['replace', ['foo', 'bar']], ['textileze', []]], var.filters)
end
def test_symbol
var = create_variable("http://disney.com/logo.gif | image: 'med' ", error_mode: :lax)
assert_equal(VariableLookup.new('http://disney.com/logo.gif'), var.name)
assert_equal([['image', ['med']]], var.filters)
end
def test_string_to_filter
var = create_variable("'http://disney.com/logo.gif' | image: 'med' ")
assert_equal('http://disney.com/logo.gif', var.name)
@@ -105,14 +99,12 @@ class VariableUnitTest < Minitest::Test
end
def test_dashes
assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name)
assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name)
assert_equal(VariableLookup.parse('foo-bar'), create_variable('foo-bar').name)
assert_equal(VariableLookup.parse('foo-bar-2'), create_variable('foo-bar-2').name)
with_error_mode(:strict) do
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
end
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
end
def test_string_with_special_chars
@@ -122,27 +114,47 @@ class VariableUnitTest < Minitest::Test
def test_string_dot
var = create_variable(%( test.test ))
assert_equal(VariableLookup.new('test.test'), var.name)
assert_equal(VariableLookup.parse('test.test'), var.name)
end
def test_filter_with_keyword_arguments
var = create_variable(%( hello | things: greeting: "world", farewell: 'goodbye'))
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal([['things', [], { 'greeting' => 'world', 'farewell' => 'goodbye' }]], var.filters)
end
def test_lax_filter_argument_parsing
var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax)
assert_equal(VariableLookup.new('number_of_comments'), var.name)
assert_equal([['pluralize', ['comment', 'comments']]], var.filters)
end
def test_filter_argument_parsing
# optional colon
var = create_variable(%(n | f1 | f2:))
assert_equal([['f1', []], ['f2', []]], var.filters)
def test_strict_filter_argument_parsing
with_error_mode(:strict) do
assert_raises(SyntaxError) do
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
end
end
# missing argument throws error
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
# arg requires colon
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
# trailing comma doesn't throw
create_variable(%(n | f1: 1, 2, 3, | f2:))
# missing comma throws error
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
# positional and kwargs parsing
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
# positional and kwargs mixed
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
# positional and kwargs intermixed (pos1, key1: val1, pos2)
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
# string key throws
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
end
def test_output_raw_source_of_variable
@@ -151,7 +163,7 @@ class VariableUnitTest < Minitest::Test
end
def test_variable_lookup_interface
lookup = VariableLookup.new('a.b.c')
lookup = VariableLookup.parse('a.b.c')
assert_equal('a', lookup.name)
assert_equal(['b', 'c'], lookup.lookups)
end