Skip to content

Fix variable ranges, for limit, if/for scoping, append coercion & string delimiters - #18

Merged
benfaerber merged 1 commit into
masterfrom
fix/ranges-scoping-append-string-delims
Jun 6, 2026
Merged

benfaerber merged 1 commit into
masterfrom
fix/ranges-scoping-append-string-delims

Conversation

@benfaerber

Copy link
Copy Markdown
Owner

Several fixes that bring liquid_ml in line with the Ruby/Shopify Liquid
reference implementation. Each is covered by new regression tests in
test/test_bug_fixes.ml; the full suite (incl. the real Shopify theme
fixtures) passes — 294 tests.

What's fixed

Variable ranges (1..n) / (a..b)

Previously only integer literals were accepted; a variable bound silently
produced an empty loop. LexRange now carries two lex_values, and a new
deferred Range of value * value is resolved at interpret time in
Values.unwrap. Descending ranges yield an empty list (matches Ruby).

{%- assign n = 4 -%}{% for i in (1..n) %}{{ i }}{% endfor %}   => 1234

for had an implicit limit of 50

Ruby's for has no default limit; only an explicit limit: caps it. The
default is now effectively unbounded.

if/for no longer discard in-body assignments

This matched neither Ruby nor Shopify: a variable assigned inside an
if/for body was deleted when the block ended. Ruby writes assign to the
root scope, so those values persist.

{% if true %}{% assign x = 5 %}{% endif %}{{ x }}                  => 5
{% for i in (1..3) %}{% assign last = i %}{% endfor %}{{ last }}    => 3

The loop variable and forloop are still scoped to the loop (restored to
their pre-loop state afterwards), and break/continue no longer leak past
the loop. Verified with nested loops and shadowed loop variables
({%- assign i='before' -%}{% for i in (1..3) %}{% endfor %}{{ i }} => before).

append / prepend coerce non-string arguments

Ruby coerces operands to strings. These now stringify the same way {{ }}
output does.

{{ 'n=' | append: 5 }}    => n=5

Lexer: delimiters inside string literals

The block-token scanner is now quote-aware: {{, }}, {%, %} inside a
string literal in a tag are no longer mistaken for block delimiters. Raw text
is unaffected (quotes there are not treated specially), so well-formed
templates are unchanged — this only makes the lexer accept a superset.

{% assign o = '{% raw %}' %}{{ o }}   => {% raw %}

Compatibility note

liquid_ml renders nil/undefined as the literal "nil" (Ruby renders "").
That is pre-existing and unchanged here, but append/prepend now surface it
({{ missing | append: 'y' }} => "nily"). I kept append consistent with
{{ }} interpolation, so fixing the global nil→"" rendering later would
make append Ruby-correct automatically. Happy to do that in a follow-up.

Files

  • liquid_syntax/tokens.ml, liquid_syntax/syntax.ml, liquid_syntax/values.ml — Range value + resolution, default for limit
  • liquid_parser/lexer.ml — range-bound atoms, quote-aware block scanner
  • liquid_std/liquid_string.ml — append/prepend coercion
  • liquid_interpreter/interpreter.ml — if/for scoping
  • test/test_bug_fixes.ml (+ test/test_runner.ml) — 18 regression tests

…ng delimiters

Brings several behaviors in line with the Ruby/Shopify Liquid reference
implementation. All existing tests pass; adds test/test_bug_fixes.ml.

- Variable ranges: `(1..n)` and `(a..b)` now work, not just integer
  literals. `LexRange` carries two `lex_value`s and a new deferred
  `Range of value * value` is resolved at interpret time in `Values.unwrap`
  (descending ranges yield an empty list, matching Ruby).

- `for` default limit: removed the implicit cap of 50. Ruby has no default
  limit; only an explicit `limit:` caps iteration.

- if/for scoping: `if`/`else` no longer discard variables assigned inside a
  branch, and a `for` body's assignments now persist after the loop (Ruby
  writes assigns to the root scope). The loop variable and `forloop` are
  still restored to their pre-loop state, and break/continue no longer leak
  past the loop (verified with nested loops and shadowed loop variables).

- append/prepend: coerce non-string arguments using the same stringification
  as `{{ }}` output, so `{{ 'n=' | append: 5 }}` => "n=5".

- Lexer: the block-token scanner is now quote-aware, so `{{`, `}}`, `{%`,
  `%}` inside a string literal in a tag are no longer mistaken for
  delimiters (e.g. `{% assign o = '{% raw %}' %}`). Raw text is unaffected.
@codecov

codecov Bot commented May 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.12121% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
liquid_parser/lexer.ml 82.97% 8 Missing ⚠️
liquid_syntax/values.ml 55.55% 4 Missing ⚠️
liquid_std/liquid_string.ml 66.66% 2 Missing ⚠️
liquid_syntax/syntax.ml 66.66% 2 Missing ⚠️
liquid_interpreter/interpreter.ml 92.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@benfaerber
benfaerber merged commit 598ef27 into master Jun 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant