Shopify Liquid compatibility + engine hardening + RenderAudit#145
Open
joaqu1m wants to merge 13 commits intoosteele:mainfrom
Open
Shopify Liquid compatibility + engine hardening + RenderAudit#145joaqu1m wants to merge 13 commits intoosteele:mainfrom
joaqu1m wants to merge 13 commits intoosteele:mainfrom
Conversation
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>
…y implementations
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Shopify Liquid compatibility + engine hardening + RenderAudit
This PR brings
osteele/liquidmuch 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:
echo,liquid,render,layout/block, and moreAll new behaviour has been verified against the Shopify Liquid documentation, the Ruby
liquidgem source, and the LiquidJS test suite. Where the three diverge, the Ruby gem is the tiebreaker.Bug Fixes
Core expression / comparison
uint,uint8,uint16,uint32,uint64,uintptr) now work in{% if %},{% unless %},{% case/when %}, and all comparison operators.NormalizeNumber()converts every integer variant toint64/uint64before comparison; theint64/uint64pair is compared without a lossy float64 cast, preserving precision for values aboveMaxInt64. Array indexing and loop bounds (for i in list limit: n) also failed for unsigned types — both are fixed.range contains:(1..5) contains 3returnedfalsebecauseRangewas wrapped as a reflectionstructValue. Replaced with a dedicatedrangeValuetype whoseContainsmethod does integer bounds checking.notoperator precedence: Grammar corrected tocond AND cond/cond OR condwith%rightassociativity;not x or not ynow parses correctly.case/when empty/case/when blank:Evaluate()called.Interface()on the sentinel values, stripping their identity. Fixed by detecting the internalLiquidSentinelinterface before the reflection unwrap.Tags
forwith nil collection now renders theelsebranch instead of silently emitting nothing.formodifier order:offset → limit → reversedis now always applied in Ruby order regardless of declaration order in the source.capturewith quoted variable name:{% capture 'var' %}/{% capture "var" %}now strips quotes before assigning.Filters
capitalize"MY TITLE"→"My title"strip_html<script>/<style>blocks with content (case-insensitive) and<!-- -->comments before stripping tagsnewline_to_br\n→<br />\n(preserves the newline) instead of<br />modulo-10 | modulo: 3→2); also returnsZeroDivisionErroron zero divisordivided_byfloat / intreturns float; integer division only when both are integersdatenil | date: fmtreturnsnil(was empty string)sort/sort_naturalsort_naturalno longer panics on nilslicestrip_newlines\r\n,\r, and\n(Windows line-ending support)truncate/truncatewordsn ≤ len(ellipsis), exact-fit strings,n=0, whitespace normalisationfirst/lastWhitespace 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
Cacherace condition:render/config.goCachewas a plainmap[string][]bytewritten byParseTemplateAndCacheand read concurrently by{% include %}, causingfatal error: concurrent map writes. Replaced withsync.Map.wrapRenderErroroverwrote theMarkupContextof 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.assignpropagates 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, andfor collection as itemiteration.includeenhancements{% 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, andfor collection as aliasare 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
squishhescapejsonifyjsonbase64_url_safe_encode/base64_url_safe_decodeencoding/base64.URLEncoding)rawdate_to_xmlschema%Y-%m-%dT%H:%M:%S%:z)date_to_rfc822date_to_string/date_to_long_stringordinal/ US / UK variantswhere_exppeople | where_exp: "p", "p.age >= 18"reject_exp/group_by_exp/find_exp/find_index_exp/has_expglobal_filterEngine.SetGlobalFilter— applied to every{{ }}outputEnhanced filters
date:"today"is now an alias for"now".lstrip/rstrip/strip: Optionalcharsargument ({{ s | lstrip: "-*" }}).compact: Optionalpropertyargument — removes items whereitem["prop"]is nil.uniq: Optionalpropertyargument — deduplicates byitem["prop"].default: Now acceptsallow_false: truekeyword argument to skip activation forfalsevalues.Keyword arguments in filters
Filters now support named arguments via the new
NamedArgstruct. The parser, grammar, and filter-dispatch layer all supportfilter: positional, key: valuesyntax. Thedefaultfilter is the first consumer.Engine and API Additions
Per-render options (
RenderOption)WithStrictVariables()UndefinedVariableErrorWithLaxFilters()WithGlobals(map)WithGlobalFilter(fn){{ }}output for this callWithErrorHandler(fn)WithContext(ctx)WithSizeLimit(n)nbytesEngine-level additions
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:This prevents a class of data races where configuration mutations interleave with concurrent renders.
UnregisterTagdeliberately has no freeze guard (it is intended for hot-reload and test teardown).Error types
Both
parser.Errorandrender.Errorinterfaces now exposeMessage()(message without prefix/location) andMarkupContext()(raw source of the failing token).ParseError.Error()uses the prefix"Liquid syntax error";RenderError.Error()uses"Liquid error".Static analysis additions
echo,increment/decrement(as locals),include/renderwith-expressions and key-value args, looplimit/offsetexpressions, andliquidtag inner variables.ParseTreeVisitorAPI:Template.Walk(WalkFunc)andTemplate.ParseTree() *TemplateNodeexpose 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 byrenderand byRenderAudit.Engine.SetGlobals/GetGlobals: Engine-wide variables accessible in all templates, shadowed by local bindings andassign.Drop additions
DropMethodMissing: Drops implementingLiquidMethodMissing(name string) anyreceive calls for unknown field names.ContextDrop/DropRenderContext: Drops implementingSetContext(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
RenderAuditis a normal render with observability layered on top. Errors that would normally abort rendering are instead captured asDiagnosticentries and the render continues, accumulating all errors inAuditError.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
Expressioninresult.Expressionsrepresents one Liquid construct visited during rendering, in execution order.Kindis the discriminator; exactly one trace field is populated."variable"{{ expr }}Variable *VariableTrace"condition"{% if %},{% unless %},{% case %}Condition *ConditionTrace"iteration"{% for %},{% tablerow %}Iteration *IterationTrace"assignment"{% assign %}Assignment *AssignmentTrace"capture"{% capture %}Capture *CaptureTraceEvery
Expressionalso carriesSource(raw template text including delimiters),Range(1-based line + column),Depth(block-nesting depth, 0 = top level), and an optionalError *Diagnosticwhen that node caused a runtime error.VariableTrace
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.Executedtells you which branch body actually ran.Comparisons inside a condition are captured in a typed tree:
IterationTrace
One trace item per loop block (not per iteration). Inner expressions from each iteration appear sequentially in
result.Expressionsafter theIterationTraceentry, up toMaxIterationTraceItemsiterations.AssignmentTrace
CaptureTrace
Diagnostics
AuditResult.Diagnosticsfollows the LSP Diagnostic pattern. Errors that are real Liquid errors have severity"error"; silent but suspicious behaviour has"warning"; static observations have"info".argument-errordivided_by: 0, invalid filter argsundefined-filtersyntax-errorunclosed-tag{% if %}without{% endif %}(Validate)undefined-variableWithStrictVariables()activetype-mismatch"active" == 1)not-iterable{% for %}over non-iterable (int, bool, string)nil-dereferencecustomer.address.citywhenaddressis nil)empty-blockEach
DiagnosticcarriesRange(line + column),Severity,Code,Message,Source(raw template text), and an optionalRelated []RelatedInfofor 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.
AuditError
AuditErroris alwaysnilwhen 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
liquidgem and LiquidJS. New E2E test files:render_audit_test.goRenderAudit— 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),AuditOptionsflags,AuditErroraccumulation, render-continues-on-error, JSON serialisation roundtrip, interaction withWithStrictVariables/WithGlobals/WithLaxFilters/WithSizeLimit,Validate()for empty blocks and undefined filtersb1_numeric_types_test.gob2_truthiness_test.godefaultedge casesb3_whitespace_ctrl_test.gob4_b6_error_test.goMarkupContext,LineNumber, chain walkingb5_concurrency_test.gob7_context_scope_test.godrops_e2e_test.gos1_tags_test.gos2_filters_e2e_test.gos4_expressions_e2e_test.gos5_variable_access_e2e_test.gos8_engine_config_e2e_test.gos9_static_analysis_e2e_test.gos10_error_handling_e2e_test.gos11_whitespace_e2e_test.goChecklist
make testpasses.make lintpasses.