Skip to content

Shopify Liquid compatibility + engine hardening + RenderAudit#145

Open
joaqu1m wants to merge 13 commits intoosteele:mainfrom
joaqu1m:main
Open

Shopify Liquid compatibility + engine hardening + RenderAudit#145
joaqu1m wants to merge 13 commits intoosteele:mainfrom
joaqu1m:main

Conversation

@joaqu1m
Copy link
Copy Markdown

@joaqu1m joaqu1m commented Apr 6, 2026

Shopify Liquid compatibility + engine hardening + RenderAudit

This PR brings osteele/liquid much closer to full Shopify Liquid compatibility, adds a large number of Ruby-and-LiquidJS-ported test cases, hardens the engine for concurrent use, and extends the public API with several long-requested features.

The work is organised into five areas:

  1. Bug fixes — behaviour divergences from the Shopify spec
  2. New tagsecho, liquid, render, layout/block, and more
  3. New and enhanced filters
  4. Engine and API additions
  5. RenderAudit — execution tracing

All new behaviour has been verified against the Shopify Liquid documentation, the Ruby liquid gem source, and the LiquidJS test suite. Where the three diverge, the Ruby gem is the tiebreaker.


Bug Fixes

Core expression / comparison

  • Unsigned integer types (uint, uint8, uint16, uint32, uint64, uintptr) now work in {% if %}, {% unless %}, {% case/when %}, and all comparison operators. NormalizeNumber() converts every integer variant to int64/uint64 before comparison; the int64/uint64 pair is compared without a lossy float64 cast, preserving precision for values above MaxInt64. Array indexing and loop bounds (for i in list limit: n) also failed for unsigned types — both are fixed.
  • range contains: (1..5) contains 3 returned false because Range was wrapped as a reflection structValue. Replaced with a dedicated rangeValue type whose Contains method does integer bounds checking.
  • not operator precedence: Grammar corrected to cond AND cond / cond OR cond with %right associativity; not x or not y now parses correctly.
  • case/when empty / case/when blank: Evaluate() called .Interface() on the sentinel values, stripping their identity. Fixed by detecting the internal LiquidSentinel interface before the reflection unwrap.

Tags

  • for with nil collection now renders the else branch instead of silently emitting nothing.
  • for modifier order: offset → limit → reversed is now always applied in Ruby order regardless of declaration order in the source.
  • capture with quoted variable name: {% capture 'var' %} / {% capture "var" %} now strips quotes before assigning.

Filters

Filter Fix
capitalize Lowercases the rest of the string — "MY TITLE""My title"
strip_html Now removes <script>/<style> blocks with content (case-insensitive) and <!-- --> comments before stripping tags
newline_to_br \n<br />\n (preserves the newline) instead of <br />
modulo Floor modulo: result has the same sign as the divisor (-10 | modulo: 32); also returns ZeroDivisionError on zero divisor
divided_by Type-preserving: float / int returns float; integer division only when both are integers
date nil | date: fmt returns nil (was empty string)
sort / sort_natural Nil elements go to the end (nil-last); sort_natural no longer panics on nil
slice Negative length clamped to zero instead of panicking
strip_newlines Now removes \r\n, \r, and \n (Windows line-ending support)
truncate / truncatewords Edge cases: n ≤ len(ellipsis), exact-fit strings, n=0, whitespace normalisation
first / last Now work on strings, returning the first/last Unicode rune

Whitespace control

  • {%- # comment -%} with space: Scanner regex updated to {%-?\s*#, allowing an optional space between - and #.
  • {{-}} trim without expression: Was a syntax error. The scanner strips the spurious - arg and the parser silently drops empty output tokens.

Concurrency / thread-safety

  • Cache race condition: render/config.go Cache was a plain map[string][]byte written by ParseTemplateAndCache and read concurrently by {% include %}, causing fatal error: concurrent map writes. Replaced with sync.Map.
  • Error context lost in nested blocks: wrapRenderError overwrote the MarkupContext of an inner node with the outer block source. Fixed by preserving errors that already carry a line number.

New Tags

echo

{% liquid
  echo product.title | upcase
%}

Equivalent to {{ expr }} but usable inside {% liquid %} blocks. Follows Ruby semantics (expression is required).

liquid (multi-line tag block)

{% liquid
  assign greeting = "Hello"
  if user
    echo greeting | append: ", " | append: user.name
  endif
%}

Every non-empty, non-comment line is compiled and rendered as a tag in the current scope. Lines starting with # are inline comments. assign propagates to the outer context.

# inline comments

{%# This is a comment and produces no output %}
{%- # Trim markers work too -%}

doc / enddoc

{% doc %}
  This block documents the template. It is discarded at parse time.
{% enddoc %}

ifchanged

{% for item in items %}
  {% ifchanged %}{{ item.category }}{% endifchanged %}
  {{ item.name }}
{% endfor %}

Emits the block only when its rendered content differs from the previous invocation. State is per render context (reset between renders).

render (isolated scope)

{% render 'product-card', product: featured, show_price: true %}
{% render 'row' for products as product %}
{% render 'header' with site %}

Renders a sub-template in a fully isolated scope — the child cannot read or write the parent's variables. Supports with var [as alias], key-value arguments, and for collection as item iteration.

include enhancements

{% include 'snippet' with user as current_user %}
{% include 'tag' for tags as tag %}
{% include 'meta', title: page.title, noindex: true %}

with … as alias, key-value named arguments, and for collection as alias are now all supported.

layout / block

{# child.html #}
{% layout 'base' %}
{% block title %}My Page{% endblock %}
{% block content %}
  <p>Hello!</p>
{% endblock %}

{# base.html #}
<title>{% block title %}Default{% endblock %}</title>
<body>{% block content %}{% endblock %}</body>

Template inheritance via named slots with fallback content.


New and Enhanced Filters

New filters

Filter Description
squish Strip + collapse internal whitespace
h Alias for escape
jsonify Alias for json
base64_url_safe_encode / base64_url_safe_decode URL-safe Base64 (encoding/base64.URLEncoding)
raw Mark a value as HTML-safe, bypassing auto-escaping
date_to_xmlschema ISO 8601 with timezone (%Y-%m-%dT%H:%M:%S%:z)
date_to_rfc822 RFC 822 date format
date_to_string / date_to_long_string Jekyll-style dates with optional ordinal / US / UK variants
where_exp people | where_exp: "p", "p.age >= 18"
reject_exp / group_by_exp / find_exp / find_index_exp / has_exp Expression-based array filters
global_filter Via Engine.SetGlobalFilter — applied to every {{ }} output

Enhanced filters

  • date: "today" is now an alias for "now".
  • lstrip / rstrip / strip: Optional chars argument ({{ s | lstrip: "-*" }}).
  • compact: Optional property argument — removes items where item["prop"] is nil.
  • uniq: Optional property argument — deduplicates by item["prop"].
  • default: Now accepts allow_false: true keyword argument to skip activation for false values.

Keyword arguments in filters

Filters now support named arguments via the new NamedArg struct. The parser, grammar, and filter-dispatch layer all support filter: positional, key: value syntax. The default filter is the first consumer.


Engine and API Additions

Per-render options (RenderOption)

tpl.RenderString(vars,
    liquid.WithStrictVariables(),
    liquid.WithGlobals(map[string]any{"site": site}),
    liquid.WithErrorHandler(func(err error) string { return "" }),
    liquid.WithContext(ctx),
    liquid.WithSizeLimit(1 << 20),
    liquid.WithGlobalFilter(htmlSanitize),
)
Option Description
WithStrictVariables() Undefined variables → UndefinedVariableError
WithLaxFilters() Unknown filters pass through silently
WithGlobals(map) Merge extra globals for this call only
WithGlobalFilter(fn) Transform every {{ }} output for this call
WithErrorHandler(fn) Replace failing nodes with handler output; rendering continues
WithContext(ctx) Bind a Go context; stops rendering on cancellation
WithSizeLimit(n) Abort when output exceeds n bytes

Engine-level additions

eng.SetGlobals(map[string]any{"site": cfg.Site})
eng.GetGlobals()
eng.SetGlobalFilter(sanitize)
eng.LaxTags()             // unknown tags = no-ops
eng.EnableCache()         // sync.Map keyed by source string
eng.ClearCache()
eng.SetTrimTagLeft(true)
eng.SetTrimTagRight(true)
eng.SetTrimOutputLeft(true)
eng.SetTrimOutputRight(true)
eng.SetGreedy(false)      // non-greedy: only inline whitespace + 1 newline

Engine freeze pattern

The engine is frozen (made immutable) on the first call to any parse method. Calling SetGlobals, RegisterTag, StrictVariables, etc. after the engine has been used for parsing panics with a clear message:

liquid: SetGlobals() called after the engine has been used for parsing

This prevents a class of data races where configuration mutations interleave with concurrent renders. UnregisterTag deliberately has no freeze guard (it is intended for hot-reload and test teardown).

Error types

var pe *liquid.ParseError          // aka SyntaxError
var re *liquid.RenderError
var ue *liquid.UndefinedVariableError
var ze *liquid.ZeroDivisionError
var ae *liquid.ArgumentError
var ce *liquid.ContextError

errors.As(err, &pe) // always works from the top-level error

Both parser.Error and render.Error interfaces now expose Message() (message without prefix/location) and MarkupContext() (raw source of the failing token). ParseError.Error() uses the prefix "Liquid syntax error"; RenderError.Error() uses "Liquid error".

Static analysis additions

  • Variable-analysis methods now correctly track echo, increment/decrement (as locals), include/render with-expressions and key-value args, loop limit/offset expressions, and liquid tag inner variables.
  • ParseTreeVisitor API: Template.Walk(WalkFunc) and Template.ParseTree() *TemplateNode expose the compiled AST as a typed public tree (kind, tag name, source location, children).

Context / scope additions

  • SpawnIsolated: Creates a render context without inheriting parent local variables; used by render and by RenderAudit.
  • Engine.SetGlobals / GetGlobals: Engine-wide variables accessible in all templates, shadowed by local bindings and assign.

Drop additions

  • DropMethodMissing: Drops implementing LiquidMethodMissing(name string) any receive calls for unknown field names.
  • ContextDrop / DropRenderContext: Drops implementing SetContext(render.Context) receive a render-context injection before the first property access, enabling access to forloop state, bindings, etc. from within the drop.

RenderAudit — Execution Tracing

result, auditErr := tpl.RenderAudit(
    liquid.Bindings{"products": catalog},
    liquid.AuditOptions{
        TraceVariables:         true,
        TraceConditions:        true,
        TraceIterations:        true,
        TraceAssignments:       true,
        MaxIterationTraceItems: 100,
    },
    liquid.WithStrictVariables(), // any RenderOption works here too
)

// result.Output       — rendered string, identical to Render
// result.Expressions  — ordered execution trace (see kinds below)
// result.Diagnostics  — LSP-compatible warnings and errors with line + column
// auditErr            — nil on success; .Errors() gives individual typed errors

RenderAudit is a normal render with observability layered on top. Errors that would normally abort rendering are instead captured as Diagnostic entries and the render continues, accumulating all errors in AuditError.Errors().

Template.Validate() is the static-analysis counterpart — walks the AST without rendering and returns diagnostics for structural issues (empty blocks, undefined filters, unclosed tags).

Expression kinds

Each Expression in result.Expressions represents one Liquid construct visited during rendering, in execution order. Kind is the discriminator; exactly one trace field is populated.

Kind Produced by Trace field
"variable" {{ expr }} Variable *VariableTrace
"condition" {% if %}, {% unless %}, {% case %} Condition *ConditionTrace
"iteration" {% for %}, {% tablerow %} Iteration *IterationTrace
"assignment" {% assign %} Assignment *AssignmentTrace
"capture" {% capture %} Capture *CaptureTrace

Every Expression also carries Source (raw template text including delimiters), Range (1-based line + column), Depth (block-nesting depth, 0 = top level), and an optional Error *Diagnostic when that node caused a runtime error.

VariableTrace

type VariableTrace struct {
    Name     string       `json:"name"`     // "customer.name"
    Parts    []string     `json:"parts"`    // ["customer", "name"]
    Value    any          `json:"value"`    // final value after all filters
    Pipeline []FilterStep `json:"pipeline"` // per-step filter trace (empty when no filters)
}

type FilterStep struct {
    Filter string `json:"filter"` // filter name, e.g. "upcase"
    Args   []any  `json:"args"`   // arguments passed to the filter
    Input  any    `json:"input"`  // value entering this filter
    Output any    `json:"output"` // value leaving this filter
}

Example{{ customer.name | upcase | truncate: 10 }}:

{
  "kind": "variable",
  "variable": {
    "name": "customer.name",
    "parts": ["customer", "name"],
    "value": "JOAQUIM...",
    "pipeline": [
      { "filter": "upcase",   "args": [],      "input": "joaquim silva", "output": "JOAQUIM SILVA" },
      { "filter": "truncate", "args": [10,"..."], "input": "JOAQUIM SILVA", "output": "JOAQUIM..." }
    ]
  }
}

ConditionTrace

Captures all branches (if, elsif, else, when, unless) of a block, including branches that were not executed. Executed tells you which branch body actually ran.

Comparisons inside a condition are captured in a typed tree:

type ConditionBranch struct {
    Kind     string          `json:"kind"`              // "if"|"elsif"|"else"|"when"|"unless"
    Range    Range           `json:"range"`             // range of this clause header
    Executed bool            `json:"executed"`          // did this branch body run?
    Items    []ConditionItem `json:"items,omitempty"`   // comparison tree (empty for "else")
}

// ConditionItem is a union: exactly one field is non-nil.
type ConditionItem struct {
    Comparison *ComparisonTrace `json:"comparison,omitempty"`
    Group      *GroupTrace      `json:"group,omitempty"`
}

type ComparisonTrace struct {
    Expression string `json:"expression"` // raw source text, e.g. "customer.age >= 18"
    Left       any    `json:"left"`
    Operator   string `json:"operator"`   // "==", "!=", ">", ">=", "<", "<=", "contains"
    Right      any    `json:"right"`
    Result     bool   `json:"result"`
}

type GroupTrace struct {
    Operator string          `json:"operator"` // "and" | "or"
    Result   bool            `json:"result"`
    Items    []ConditionItem `json:"items"`
}

IterationTrace

One trace item per loop block (not per iteration). Inner expressions from each iteration appear sequentially in result.Expressions after the IterationTrace entry, up to MaxIterationTraceItems iterations.

type IterationTrace struct {
    Variable    string `json:"variable"`             // loop variable name: "product"
    Collection  string `json:"collection"`           // collection name: "cart.items"
    Length      int    `json:"length"`               // total items in collection
    Limit       *int   `json:"limit,omitempty"`
    Offset      *int   `json:"offset,omitempty"`
    Reversed    bool   `json:"reversed,omitempty"`
    Truncated   bool   `json:"truncated,omitempty"`  // true when MaxIterationTraceItems hit
    TracedCount int    `json:"traced_count"`         // how many iterations were traced
}

AssignmentTrace

type AssignmentTrace struct {
    Variable string       `json:"variable"`
    Path     []string     `json:"path,omitempty"` // set when dot-notation assign is used
    Value    any          `json:"value"`
    Pipeline []FilterStep `json:"pipeline"`
}

CaptureTrace

type CaptureTrace struct {
    Variable string `json:"variable"`
    Value    string `json:"value"` // the rendered block content
}

Diagnostics

AuditResult.Diagnostics follows the LSP Diagnostic pattern. Errors that are real Liquid errors have severity "error"; silent but suspicious behaviour has "warning"; static observations have "info".

Code Severity Trigger
argument-error error divided_by: 0, invalid filter args
undefined-filter error filter name not registered (Validate)
syntax-error error malformed tag syntax (Validate)
unclosed-tag error {% if %} without {% endif %} (Validate)
undefined-variable warning undefined variable when WithStrictVariables() active
type-mismatch warning comparing incompatible types (e.g. "active" == 1)
not-iterable warning {% for %} over non-iterable (int, bool, string)
nil-dereference warning intermediate path segment is nil (e.g. customer.address.city when address is nil)
empty-block info block tag with no content (Validate)

Each Diagnostic carries Range (line + column), Severity, Code, Message, Source (raw template text), and an optional Related []RelatedInfo for cross-references (e.g. pointing to the matching opening tag).

Position and Range types

Source positions use 1-based line and column numbers (LSP-compatible). The scanner now tracks column offsets alongside line numbers.

type Position struct {
    Line   int `json:"line"`   // 1-based
    Column int `json:"column"` // 1-based
}
type Range struct {
    Start Position `json:"start"`
    End   Position `json:"end"` // exclusive
}

AuditError

type AuditError struct { /* … */ }
func (e *AuditError) Error() string        // "render completed with N error(s)"
func (e *AuditError) Errors() []SourceError // individual typed errors (UndefinedVariableError, RenderError, …)

AuditError is always nil when the render completed without errors.

Complete example

{% assign title = page.title | upcase %}
<h1>{{ title }}</h1>
{% if customer.age >= 18 %}
  <p>Welcome, {{ customer.name }}!</p>
{% else %}
  <p>Restricted.</p>
{% endif %}
{% for item in cart.items %}
  <li>{{ item.name }} — ${{ item.price | times: 1.1 | round }}</li>
{% endfor %}

result.Expressions (abbreviated):

[
  { "kind": "assignment", "assignment": { "variable": "title", "value": "MY STORE", "pipeline": [{"filter":"upcase","input":"my store","output":"MY STORE","args":[]}] } },
  { "kind": "variable",   "variable":   { "name": "title", "parts": ["title"], "value": "MY STORE", "pipeline": [] } },
  { "kind": "condition",  "depth": 0,   "condition": { "branches": [
      { "kind": "if",   "executed": true,  "items": [{ "comparison": { "left": 25, "operator": ">=", "right": 18, "result": true } }] },
      { "kind": "else", "executed": false, "items": [] }
    ]}},
  { "kind": "variable",   "depth": 1,   "variable": { "name": "customer.name", "value": "João", "pipeline": [] } },
  { "kind": "iteration",  "depth": 0,   "iteration": { "variable": "item", "collection": "cart.items", "length": 2, "traced_count": 2 } },
  { "kind": "variable",   "depth": 1,   "variable": { "name": "item.name",  "value": "T-shirt", "pipeline": [] } },
  { "kind": "variable",   "depth": 1,   "variable": { "name": "item.price", "value": 55, "pipeline": [{"filter":"times","args":[1.1],"input":50,"output":55.0},{"filter":"round","args":[],"input":55.0,"output":55}] } },
  { "kind": "variable",   "depth": 1,   "variable": { "name": "item.name",  "value": "Trousers", "pipeline": [] } },
  { "kind": "variable",   "depth": 1,   "variable": { "name": "item.price", "value": 132, "pipeline": [{"filter":"times","args":[1.1],"input":120,"output":132.0},{"filter":"round","args":[],"input":132.0,"output":132}] } }
]

Tests

All new and fixed behaviour is covered by unit tests and a suite of ported integration tests from the Ruby liquid gem and LiquidJS. New E2E test files:

File Coverage
render_audit_test.go RenderAudit — variables (incl. filter pipeline, all literal types, depth), conditions (all branch kinds, comparison operators, and/or groups, ranges), iterations (limit/offset/reversed/truncation, inner expressions, tablerow), assignments (pipeline, source/range), captures (inner traces), diagnostics (all codes + severity), AuditOptions flags, AuditError accumulation, render-continues-on-error, JSON serialisation roundtrip, interaction with WithStrictVariables/WithGlobals/WithLaxFilters/WithSizeLimit, Validate() for empty blocks and undefined filters
b1_numeric_types_test.go Unsigned integer types in all operators and filters
b2_truthiness_test.go nil/false/blank/empty truthiness, default edge cases
b3_whitespace_ctrl_test.go Trim in nested loops, blocks, all tag types
b4_b6_error_test.go All error types, MarkupContext, LineNumber, chain walking
b5_concurrency_test.go Render isolation, cache race, freeze pattern (42 subtests)
b7_context_scope_test.go Scope stack, globals, isolated sub-contexts, registers (87 tests)
drops_e2e_test.go ForloopDrop, TablerowloopDrop, EmptyDrop, BlankDrop, ContextDrop, DropMethodMissing
s1_tags_test.go Ported tag tests (Ruby + LiquidJS)
s2_filters_e2e_test.go All filter behaviour, edge cases, type preservation
s4_expressions_e2e_test.go Literals, operators, ranges, escape sequences
s5_variable_access_e2e_test.go Dot, bracket, negative index, dynamic indirection (~80 tests)
s8_engine_config_e2e_test.go All engine options and per-render options
s9_static_analysis_e2e_test.go Variable segment tracking, ParseTreeVisitor
s10_error_handling_e2e_test.go Error types, prefixes, MarkupContext, chain (85 leaf tests)
s11_whitespace_e2e_test.go All trim combinations + global trim options (105 tests)

Checklist

  • I have read the contribution guidelines.
  • make test passes.
  • make lint passes.
  • New and changed code is covered by tests.
  • Changes match the documented (not just the implemented) behavior of Shopify.

joaqu1m and others added 13 commits April 1, 2026 01:08
Add Engine.GlobalVariableSegments and Engine.VariableSegments to extract
variable paths from a compiled template without rendering it.

- expressions/analysis.go: internal trackingContext and trackingValue that
  intercept Get/PropertyValue/IndexValue calls to record variable paths
- expressions/expressions.go: add Variables() [][]string to the Expression
  interface, computed lazily with sync.Once on the expression struct
- render/analysis.go: NodeAnalysis struct, TagAnalyzer/BlockAnalyzer types,
  AST walker (walkForVariables, collectLocals), and Analyze() entry point
- render/nodes.go: Analysis NodeAnalysis field on TagNode and BlockNode;
  GetExpr() accessor on ObjectNode
- render/config.go: tagAnalyzers/blockAnalyzers registries with
  AddTagAnalyzer and AddBlockAnalyzer methods
- render/compiler.go: populate Analysis at compile time for each node
- tags/analyzers.go: analyzers for assign, capture, for, tablerow, if,
  unless, and case built-in tags
- analysis.go: public API on Engine and Template
- analysis_test.go: 17 edge case tests covering assign, for, if, capture,
  case, unless, elsif, tablerow, filters, and nested scopes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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