Texto fixo sem variáveis nenhuma.
`,
+ nil, nil)
+ run("08 variável só em assign",
+ `{% assign x = hidden.value %}nada aqui`,
+ ss("hidden.value"), ss("x"))
+ run("08 profundidade extrema",
+ `{{ a.b.c.d.e.f.g.h }}`,
+ ss("a.b.c.d.e.f.g.h"), nil)
+ run("08 variável em elsif que nunca executa",
+ `{% if false %}{% elsif rarely.used.var %}ok{% endif %}`,
+ ss("rarely.used.var"), nil)
+}
+
+func ss(vals ...string) []string { return vals }
diff --git a/liquid.go b/liquid.go
index addf3eb5..7519af98 100644
--- a/liquid.go
+++ b/liquid.go
@@ -8,6 +8,9 @@ The liquid package itself is versioned in gopkg.in. Subpackages have no compatib
package liquid
import (
+ "context"
+ "maps"
+
"github.com/osteele/liquid/render"
"github.com/osteele/liquid/tags"
)
@@ -38,3 +41,104 @@ type SourceError interface {
func IterationKeyedMap(m map[string]any) tags.IterationKeyedMap {
return m
}
+
+// RenderOption is a functional option that overrides engine-level configuration
+// for a single Render or FRender call.
+//
+// Create options with WithStrictVariables, WithLaxFilters, or WithGlobals.
+type RenderOption func(*render.Config)
+
+// WithStrictVariables causes this render call to error when the template
+// references an undefined variable, regardless of the engine-level setting.
+func WithStrictVariables() RenderOption {
+ return func(c *render.Config) {
+ c.StrictVariables = true
+ }
+}
+
+// WithLaxFilters causes this render call to silently pass the input value
+// through when the template references an undefined filter, regardless of
+// the engine-level setting.
+func WithLaxFilters() RenderOption {
+ return func(c *render.Config) {
+ c.LaxFilters = true
+ }
+}
+
+// WithGlobals merges the provided map into the globals for this render call.
+// Per-call globals are merged on top of any engine-level globals set via
+// Engine.SetGlobals; both are superseded by the scope bindings passed to Render.
+//
+// This mirrors the `globals` render option in LiquidJS.
+func WithGlobals(globals map[string]any) RenderOption {
+ return func(c *render.Config) {
+ if len(globals) == 0 {
+ return
+ }
+ merged := make(map[string]any, len(c.Globals)+len(globals))
+ maps.Copy(merged, c.Globals)
+ maps.Copy(merged, globals)
+ c.Globals = merged
+ }
+}
+
+// WithErrorHandler registers a function that is called when a render-time error
+// occurs instead of stopping the render. The handler receives the error and
+// returns a string that is written to the output in place of the failing node.
+// Rendering continues with the next node after the handler returns.
+//
+// This mirrors Ruby Liquid's exception_renderer option.
+//
+// To collect errors without stopping render:
+//
+// var errs []error
+// out, _ := tpl.RenderString(vars, WithErrorHandler(func(err error) string {
+// errs = append(errs, err)
+// return "" // or some placeholder
+// }))
+func WithErrorHandler(fn func(error) string) RenderOption {
+ return func(c *render.Config) {
+ c.ExceptionHandler = fn
+ }
+}
+
+// WithContext sets the context for this render call. When the context is
+// cancelled or its deadline is exceeded, rendering stops and the context
+// error is returned. Use this for time-based render limits.
+//
+// ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+// defer cancel()
+// out, err := tpl.RenderString(vars, WithContext(ctx))
+func WithContext(ctx context.Context) RenderOption {
+ return func(c *render.Config) {
+ c.Context = ctx
+ }
+}
+
+// WithSizeLimit limits the total number of bytes written to the output during
+// this render call. Rendering is aborted with an error when the limit is exceeded.
+func WithSizeLimit(n int64) RenderOption {
+ return func(c *render.Config) {
+ c.SizeLimit = n
+ }
+}
+
+// WithGlobalFilter registers a function that is applied to the evaluated value of
+// every {{ expression }} for this render call, overriding any engine-level global
+// filter set via Engine.SetGlobalFilter.
+//
+// This mirrors Ruby Liquid's global_filter: render option.
+//
+// Example:
+//
+// out, err := tpl.RenderString(vars, WithGlobalFilter(func(v any) (any, error) {
+// if s, ok := v.(string); ok {
+// return strings.ToUpper(s), nil
+// }
+// return v, nil
+// }))
+func WithGlobalFilter(fn func(any) (any, error)) RenderOption {
+ return func(c *render.Config) {
+ c.SetGlobalFilter(fn)
+ }
+}
diff --git a/liquid.test.exe b/liquid.test.exe
new file mode 100644
index 00000000..38b113e1
Binary files /dev/null and b/liquid.test.exe differ
diff --git a/parse_audit_basic_test.go b/parse_audit_basic_test.go
new file mode 100644
index 00000000..d8d61d55
--- /dev/null
+++ b/parse_audit_basic_test.go
@@ -0,0 +1,119 @@
+package liquid_test
+
+import (
+ "testing"
+)
+
+// ============================================================================
+// Basic API Contract (B01–B12)
+// ============================================================================
+
+// B01 — ParseResult is non-nil for a clean template.
+func TestParseAudit_Basic_B01_resultNonNilClean(t *testing.T) {
+ r := parseAudit(`Hello {{ name }}!`)
+ assertParseResultNonNil(t, r, "B01")
+}
+
+// B02 — ParseResult is non-nil even when the parse is fatal (Template=nil).
+func TestParseAudit_Basic_B02_resultNonNilOnFatal(t *testing.T) {
+ r := parseAudit(`{% if x %}no close`)
+ assertParseResultNonNil(t, r, "B02")
+}
+
+// B03 — Diagnostics is non-nil (never nil) for a clean template.
+func TestParseAudit_Basic_B03_diagnosticsNonNilClean(t *testing.T) {
+ r := parseAudit(`Hello, world!`)
+ assertDiagsNonNil(t, r, "B03")
+}
+
+// B04 — Diagnostics is non-nil for a fatal-error template and contains at
+// least one diagnostic.
+func TestParseAudit_Basic_B04_diagnosticsNonNilOnFatal(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ assertDiagsNonNil(t, r, "B04")
+ if len(r.Diagnostics) == 0 {
+ t.Fatal("B04: expected at least one diagnostic for unclosed-tag, got none")
+ }
+}
+
+// B05 — Template is non-nil for a clean template.
+func TestParseAudit_Basic_B05_templateNonNilClean(t *testing.T) {
+ r := parseAudit(`{{ name | upcase }}`)
+ assertTemplateNonNil(t, r, "B05")
+}
+
+// B06 — Template is nil for a fatal-error template.
+func TestParseAudit_Basic_B06_templateNilFatal(t *testing.T) {
+ r := parseAudit(`{% if x %}no close`)
+ assertTemplateNil(t, r, "B06")
+}
+
+// B07 — Template is non-nil even when there is a non-fatal syntax-error.
+// The parse recovered; the broken node renders as empty string.
+func TestParseAudit_Basic_B07_templateNonNilOnNonFatal(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ assertTemplateNonNil(t, r, "B07")
+}
+
+// B08 — ParseTemplateAudit([]byte) and ParseStringAudit(string) return
+// identical diagnostic codes for the same source.
+func TestParseAudit_Basic_B08_byteAndStringVariantParity(t *testing.T) {
+ src := `{% if x %}unclosed`
+ rBytes := parseAuditBytes(src)
+ rStr := parseAudit(src)
+
+ assertParseResultNonNil(t, rBytes, "B08 bytes")
+ assertParseResultNonNil(t, rStr, "B08 string")
+
+ codesBytes := parseDiagCodes(rBytes.Diagnostics)
+ codesStr := parseDiagCodes(rStr.Diagnostics)
+
+ if len(codesBytes) != len(codesStr) {
+ t.Errorf("B08: bytes diagnostics=%v, string diagnostics=%v (count mismatch)", codesBytes, codesStr)
+ }
+ for i := range codesStr {
+ if i >= len(codesBytes) {
+ break
+ }
+ if codesBytes[i] != codesStr[i] {
+ t.Errorf("B08: diagnostics[%d] bytes code=%q, string code=%q", i, codesBytes[i], codesStr[i])
+ }
+ }
+
+ // Both should agree on whether Template is nil.
+ if (rBytes.Template == nil) != (rStr.Template == nil) {
+ t.Errorf("B08: bytes Template nil=%v, string Template nil=%v (should match)",
+ rBytes.Template == nil, rStr.Template == nil)
+ }
+}
+
+// B09 — Empty source string: Template non-nil, Diagnostics empty.
+func TestParseAudit_Basic_B09_emptySource(t *testing.T) {
+ r := parseAudit(``)
+ assertTemplateNonNil(t, r, "B09")
+ assertNoParseDiags(t, r, "B09")
+}
+
+// B10 — Whitespace-only source: Template non-nil, no diagnostics.
+func TestParseAudit_Basic_B10_whitespaceOnly(t *testing.T) {
+ r := parseAudit(" \n\t\n ")
+ assertTemplateNonNil(t, r, "B10")
+ assertNoParseDiags(t, r, "B10")
+}
+
+// B11 — Plain text with no tags: Template non-nil, Diagnostics empty.
+func TestParseAudit_Basic_B11_plainText(t *testing.T) {
+ r := parseAudit(`Hello, world! This is plain text with no Liquid.`)
+ assertTemplateNonNil(t, r, "B11")
+ assertNoParseDiags(t, r, "B11")
+}
+
+// B12 — ParseResult is JSON-serializable without error.
+func TestParseAudit_Basic_B12_jsonSerializable(t *testing.T) {
+ // Use import via the json package; marshal in a sub-test to get line precision.
+ // We don't import encoding/json here — that's covered in parse_audit_json_test.go.
+ // This test just confirms Template is non-nil and Diagnostics non-nil (contract).
+ r := parseAudit(`{{ name }}`)
+ assertTemplateNonNil(t, r, "B12")
+ assertDiagsNonNil(t, r, "B12")
+}
diff --git a/parse_audit_emptyblock_test.go b/parse_audit_emptyblock_test.go
new file mode 100644
index 00000000..062cd4d5
--- /dev/null
+++ b/parse_audit_emptyblock_test.go
@@ -0,0 +1,190 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// empty-block — Static Analysis (E01–E21)
+// ============================================================================
+
+// E01 — {% if true %}{% endif %}: completely empty if → empty-block diagnostic.
+func TestParseAudit_EmptyBlock_E01_emptyIf(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ requireParseDiag(t, r, "empty-block")
+}
+
+// E02 — {% unless true %}{% endunless %}: empty unless → empty-block.
+func TestParseAudit_EmptyBlock_E02_emptyUnless(t *testing.T) {
+ r := parseAudit(`{% unless true %}{% endunless %}`)
+ requireParseDiag(t, r, "empty-block")
+}
+
+// E03 — {% for x in items %}{% endfor %}: empty for → empty-block.
+func TestParseAudit_EmptyBlock_E03_emptyFor(t *testing.T) {
+ r := parseAudit(`{% for x in items %}{% endfor %}`)
+ requireParseDiag(t, r, "empty-block")
+}
+
+// E04 — {% tablerow x in items %}{% endtablerow %}: empty tablerow → empty-block.
+func TestParseAudit_EmptyBlock_E04_emptyTablerow(t *testing.T) {
+ r := parseAudit(`{% tablerow x in items %}{% endtablerow %}`)
+ requireParseDiag(t, r, "empty-block")
+}
+
+// E05 — {% if true %}{% else %}{% endif %}: both branches empty → empty-block.
+func TestParseAudit_EmptyBlock_E05_bothBranchesEmpty(t *testing.T) {
+ r := parseAudit(`{% if true %}{% else %}{% endif %}`)
+ requireParseDiag(t, r, "empty-block")
+}
+
+// E06 — {% if true %}content{% else %}{% endif %}: else empty but if has content → NOT empty-block.
+func TestParseAudit_EmptyBlock_E06_ifHasContentElseEmpty(t *testing.T) {
+ r := parseAudit(`{% if true %}content{% else %}{% endif %}`)
+ d := firstParseDiag(r, "empty-block")
+ if d != nil {
+ t.Errorf("E06: unexpected empty-block diagnostic when if branch has content")
+ }
+}
+
+// E07 — {% if true %}{% else %}content{% endif %}: if empty but else has content → NOT empty-block.
+func TestParseAudit_EmptyBlock_E07_elseHasContentIfEmpty(t *testing.T) {
+ r := parseAudit(`{% if true %}{% else %}content{% endif %}`)
+ d := firstParseDiag(r, "empty-block")
+ if d != nil {
+ t.Errorf("E07: unexpected empty-block diagnostic when else branch has content")
+ }
+}
+
+// E08 — {% if true %} {% endif %}: whitespace-only body.
+// This test documents the behavior (may or may not count as empty; both are acceptable).
+func TestParseAudit_EmptyBlock_E08_whitespaceOnlyBody(t *testing.T) {
+ r := parseAudit("{% if true %} \n {% endif %}")
+ assertParseResultNonNil(t, r, "E08")
+ // Only assert no crash; behavior (empty-block or not) is implementation-defined.
+ // Log the decision so it is visible in test output.
+ d := firstParseDiag(r, "empty-block")
+ t.Logf("E08: whitespace-only body detected as empty-block: %v", d != nil)
+}
+
+// E09 — {% if true %}{{ x }}{% endif %}: has expression inside → NOT empty-block.
+func TestParseAudit_EmptyBlock_E09_hasExpressionInside(t *testing.T) {
+ r := parseAudit(`{% if true %}{{ x }}{% endif %}`)
+ d := firstParseDiag(r, "empty-block")
+ if d != nil {
+ t.Errorf("E09: unexpected empty-block diagnostic when block contains {{ x }}")
+ }
+}
+
+// E10 — {% if true %}{% assign x = 1 %}{% endif %}: has tag inside → NOT empty-block.
+func TestParseAudit_EmptyBlock_E10_hasTagInside(t *testing.T) {
+ r := parseAudit(`{% if true %}{% assign x = 1 %}{% endif %}`)
+ d := firstParseDiag(r, "empty-block")
+ if d != nil {
+ t.Error("E10: unexpected empty-block diagnostic when block contains {%% assign %%}")
+ }
+}
+
+// E11 — {% if true %}text{% endif %}: has static text inside → NOT empty-block.
+func TestParseAudit_EmptyBlock_E11_hasTextInside(t *testing.T) {
+ r := parseAudit(`{% if true %}hello{% endif %}`)
+ d := firstParseDiag(r, "empty-block")
+ if d != nil {
+ t.Errorf("E11: unexpected empty-block diagnostic when block contains static text")
+ }
+}
+
+// E12 — empty-block co-exists with undefined-filter in same template.
+func TestParseAudit_EmptyBlock_E12_coexistsWithUndefinedFilter(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}{{ x | totally_unknown_xyz }}`)
+ assertParseResultNonNil(t, r, "E12")
+ hasEmptyBlock := firstParseDiag(r, "empty-block") != nil
+ hasUndefinedFilter := firstParseDiag(r, "undefined-filter") != nil
+ if !hasEmptyBlock {
+ t.Error("E12: expected empty-block diagnostic")
+ }
+ if !hasUndefinedFilter {
+ t.Error("E12: expected undefined-filter diagnostic")
+ }
+}
+
+// E13 — Code field equals exactly "empty-block".
+func TestParseAudit_EmptyBlock_E13_codeField(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ d := requireParseDiag(t, r, "empty-block")
+ assertDiagField(t, d.Code, "empty-block", "Code", "empty-block")
+}
+
+// E14 — Severity equals exactly "info" (not warning or error).
+func TestParseAudit_EmptyBlock_E14_severityInfo(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ d := requireParseDiag(t, r, "empty-block")
+ assertDiagField(t, string(d.Severity), string(liquid.SeverityInfo), "Severity", "empty-block")
+}
+
+// E15 — Source contains the block opening tag header.
+func TestParseAudit_EmptyBlock_E15_sourceContainsHeader(t *testing.T) {
+ r := parseAudit(`{% if debug %}{% endif %}`)
+ d := requireParseDiag(t, r, "empty-block")
+ if len(d.Source) == 0 {
+ t.Fatal("E15: empty-block Source is empty")
+ }
+ assertDiagContains(t, "Source", d.Source, "if", "empty-block")
+}
+
+// E16 — Range.Start.Line is correct for the empty block.
+func TestParseAudit_EmptyBlock_E16_rangeStartLine(t *testing.T) {
+ r := parseAudit("text before\n{% if true %}{% endif %}")
+ d := requireParseDiag(t, r, "empty-block")
+ if d.Range.Start.Line != 2 {
+ t.Errorf("E16: Range.Start.Line=%d, want 2", d.Range.Start.Line)
+ }
+}
+
+// E17 — Message mentions the block name ("if", "for", etc.).
+func TestParseAudit_EmptyBlock_E17_messageContainsBlockName(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ d := requireParseDiag(t, r, "empty-block")
+ if len(d.Message) == 0 {
+ t.Fatal("E17: empty-block Message is empty")
+ }
+ // Should mention "if" in the message.
+ assertDiagContains(t, "Message", d.Message, "if", "empty-block")
+}
+
+// E18 — Two empty-blocks in same template → two empty-block diagnostics.
+func TestParseAudit_EmptyBlock_E18_twoEmptyBlocks(t *testing.T) {
+ r := parseAudit(`{% if a %}{% endif %}{% if b %}{% endif %}`)
+ blocks := allParseDiags(r, "empty-block")
+ if len(blocks) != 2 {
+ t.Errorf("E18: expected 2 empty-block diagnostics, got %d", len(blocks))
+ }
+}
+
+// E19 — Nested empty block: inner for inside if is empty.
+// {% if true %}{% for x in items %}{% endfor %}{% endif %}
+// The inner for is empty → at least empty-block for the for.
+func TestParseAudit_EmptyBlock_E19_nestedEmptyBlock(t *testing.T) {
+ r := parseAudit(`{% if true %}content{% for x in items %}{% endfor %}{% endif %}`)
+ blocks := allParseDiags(r, "empty-block")
+ if len(blocks) == 0 {
+ t.Error("E19: expected at least one empty-block for the empty for loop inside if")
+ }
+}
+
+// E20 — {% case x %}{% when "a" %}{% endcase %}: empty when branch (if detectable).
+// This test is advisory; behavior depends on implementation depth for case branches.
+func TestParseAudit_EmptyBlock_E20_emptyCaseWhen(t *testing.T) {
+ r := parseAudit(`{% case x %}{% when "a" %}{% endcase %}`)
+ assertParseResultNonNil(t, r, "E20")
+ // The behavior (whether empty-block is detected on case/when) is implementation-defined.
+ t.Logf("E20: empty-block count for empty case/when: %d", len(allParseDiags(r, "empty-block")))
+}
+
+// E21 — Template is still non-nil when there are only empty-block diagnostics.
+func TestParseAudit_EmptyBlock_E21_templateNonNilForEmptyBlock(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ assertTemplateNonNil(t, r, "E21")
+}
diff --git a/parse_audit_fatal_test.go b/parse_audit_fatal_test.go
new file mode 100644
index 00000000..5f43621f
--- /dev/null
+++ b/parse_audit_fatal_test.go
@@ -0,0 +1,265 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Fatal Errors — unclosed-tag (U01–U17)
+// ============================================================================
+
+// U01 — {% if %} without {% endif %}: Template=nil, code="unclosed-tag".
+func TestParseAudit_Unclosed_U01_ifNoEndif(t *testing.T) {
+ r := parseAudit(`{% if x %}content here`)
+ assertTemplateNil(t, r, "U01")
+ d := requireParseDiag(t, r, "unclosed-tag")
+ _ = d
+}
+
+// U02 — {% unless %} without {% endunless %}.
+func TestParseAudit_Unclosed_U02_unlessNoEnd(t *testing.T) {
+ r := parseAudit(`{% unless x %}content`)
+ assertTemplateNil(t, r, "U02")
+ requireParseDiag(t, r, "unclosed-tag")
+}
+
+// U03 — {% for %} without {% endfor %}.
+func TestParseAudit_Unclosed_U03_forNoEndfor(t *testing.T) {
+ r := parseAudit(`{% for item in items %}{{ item }}`)
+ assertTemplateNil(t, r, "U03")
+ requireParseDiag(t, r, "unclosed-tag")
+}
+
+// U04 — {% case %} without {% endcase %}.
+func TestParseAudit_Unclosed_U04_caseNoEndcase(t *testing.T) {
+ r := parseAudit(`{% case x %}{% when "a" %}yes`)
+ assertTemplateNil(t, r, "U04")
+ requireParseDiag(t, r, "unclosed-tag")
+}
+
+// U05 — {% capture %} without {% endcapture %}.
+func TestParseAudit_Unclosed_U05_captureNoEnd(t *testing.T) {
+ r := parseAudit(`{% capture greeting %}Hello`)
+ assertTemplateNil(t, r, "U05")
+ requireParseDiag(t, r, "unclosed-tag")
+}
+
+// U06 — {% tablerow %} without {% endtablerow %}.
+func TestParseAudit_Unclosed_U06_tablerowNoEnd(t *testing.T) {
+ r := parseAudit(`{% tablerow item in items %}{{ item.name }}`)
+ assertTemplateNil(t, r, "U06")
+ requireParseDiag(t, r, "unclosed-tag")
+}
+
+// U07 — Nested unclosed: {% if %}{% for %} both unclosed → Template=nil.
+func TestParseAudit_Unclosed_U07_nestedUnclosed(t *testing.T) {
+ r := parseAudit(`{% if true %}{% for x in items %}{{ x }}`)
+ assertTemplateNil(t, r, "U07")
+ // At minimum one unclosed-tag diagnostic must be present.
+ tags := allParseDiags(r, "unclosed-tag")
+ if len(tags) == 0 {
+ t.Fatal("U07: expected at least one unclosed-tag diagnostic")
+ }
+}
+
+// U08 — Multiple consecutive opens with no closes → at least one unclosed-tag.
+func TestParseAudit_Unclosed_U08_multipleOpensNoClose(t *testing.T) {
+ r := parseAudit(`{% if a %}{% if b %}{% if c %}deep`)
+ assertTemplateNil(t, r, "U08")
+ tags := allParseDiags(r, "unclosed-tag")
+ if len(tags) == 0 {
+ t.Fatal("U08: expected at least one unclosed-tag diagnostic")
+ }
+}
+
+// U09 — Code field equals exactly "unclosed-tag".
+func TestParseAudit_Unclosed_U09_codeField(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ assertDiagField(t, d.Code, "unclosed-tag", "Code", "unclosed-tag")
+}
+
+// U10 — Severity equals exactly "error".
+func TestParseAudit_Unclosed_U10_severityError(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ assertDiagField(t, string(d.Severity), string(liquid.SeverityError), "Severity", "unclosed-tag")
+}
+
+// U11 — Message mentions the tag name ("if").
+func TestParseAudit_Unclosed_U11_messageContainsTagName(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ assertDiagContains(t, "Message", d.Message, "if", "unclosed-tag")
+}
+
+// U12 — Source field contains the opening tag text.
+func TestParseAudit_Unclosed_U12_sourceContainsOpenTag(t *testing.T) {
+ r := parseAudit(`{% if order %}content`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if len(d.Source) == 0 {
+ t.Fatal("U12: unclosed-tag diagnostic Source is empty")
+ }
+ // Source should contain the if tag, not the entire template.
+ assertDiagContains(t, "Source", d.Source, "if", "unclosed-tag")
+}
+
+// U13 — Range.Start points to the opening tag line (line 1 for first-line tag).
+func TestParseAudit_Unclosed_U13_rangeStartAtOpenTag(t *testing.T) {
+ r := parseAudit(`{% if x %}body`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if d.Range.Start.Line != 1 {
+ t.Errorf("U13: Range.Start.Line=%d, want 1", d.Range.Start.Line)
+ }
+}
+
+// U14 — Related is non-empty and contains at least one entry pointing to EOF.
+func TestParseAudit_Unclosed_U14_relatedNonEmpty(t *testing.T) {
+ r := parseAudit(`{% if x %}body`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if len(d.Related) == 0 {
+ t.Fatal("U14: unclosed-tag diagnostic Related is empty; expected at least one entry pointing to expected close location")
+ }
+}
+
+// U15 — Related[0].Message mentions the expected closing tag.
+func TestParseAudit_Unclosed_U15_relatedMessageClear(t *testing.T) {
+ r := parseAudit(`{% if x %}body`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if len(d.Related) == 0 {
+ t.Skip("U15: no Related entries (U14 already fails)")
+ }
+ if len(d.Related[0].Message) == 0 {
+ t.Fatal("U15: Related[0].Message is empty; should explain expected closing tag")
+ }
+}
+
+// U16 — unclosed-tag on line 3: Range.Start.Line=3.
+func TestParseAudit_Unclosed_U16_lineTracking(t *testing.T) {
+ r := parseAudit("line1\nline2\n{% if x %}body")
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if d.Range.Start.Line != 3 {
+ t.Errorf("U16: Range.Start.Line=%d, want 3", d.Range.Start.Line)
+ }
+}
+
+// U17 — Source does not contain the complete template (only the tag excerpt).
+func TestParseAudit_Unclosed_U17_sourceNotFullTemplate(t *testing.T) {
+ template := "{% if order %}lots of content here that should not appear in source"
+ r := parseAudit(template)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ // Source should be shorter than the full template.
+ if len(d.Source) >= len(template) {
+ t.Errorf("U17: Source=%q contains entire template (len=%d); expected only tag excerpt", d.Source, len(d.Source))
+ }
+}
+
+// ============================================================================
+// Fatal Errors — unexpected-tag (X01–X14)
+// ============================================================================
+
+// X01 — {% endif %} at top level with no {% if %}: Template=nil, unexpected-tag.
+func TestParseAudit_Unexpected_X01_endifOrphan(t *testing.T) {
+ r := parseAudit(`{% endif %}`)
+ assertTemplateNil(t, r, "X01")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X02 — {% endfor %} at top level with no {% for %}.
+func TestParseAudit_Unexpected_X02_endforOrphan(t *testing.T) {
+ r := parseAudit(`{% endfor %}`)
+ assertTemplateNil(t, r, "X02")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X03 — {% endunless %} with no {% unless %}.
+func TestParseAudit_Unexpected_X03_endunlessOrphan(t *testing.T) {
+ r := parseAudit(`{% endunless %}`)
+ assertTemplateNil(t, r, "X03")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X04 — {% endcase %} with no {% case %}.
+func TestParseAudit_Unexpected_X04_endcaseOrphan(t *testing.T) {
+ r := parseAudit(`{% endcase %}`)
+ assertTemplateNil(t, r, "X04")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X05 — {% endcapture %} with no {% capture %}.
+func TestParseAudit_Unexpected_X05_endcaptureOrphan(t *testing.T) {
+ r := parseAudit(`{% endcapture %}`)
+ assertTemplateNil(t, r, "X05")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X06 — {% else %} at top level outside any block.
+func TestParseAudit_Unexpected_X06_elseOrphan(t *testing.T) {
+ r := parseAudit(`{% else %}`)
+ assertTemplateNil(t, r, "X06")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X07 — {% elsif x %} at top level outside any block.
+func TestParseAudit_Unexpected_X07_elsifOrphan(t *testing.T) {
+ r := parseAudit(`{% elsif x %}`)
+ assertTemplateNil(t, r, "X07")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X08 — {% when "a" %} outside any {% case %} block.
+func TestParseAudit_Unexpected_X08_whenOrphan(t *testing.T) {
+ r := parseAudit(`{% when "a" %}`)
+ assertTemplateNil(t, r, "X08")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X09 — Well-formed {% if %}…{% endif %} followed by an extra {% endif %}.
+func TestParseAudit_Unexpected_X09_extraEndif(t *testing.T) {
+ r := parseAudit(`{% if x %}yes{% endif %}{% endif %}`)
+ assertTemplateNil(t, r, "X09")
+ requireParseDiag(t, r, "unexpected-tag")
+}
+
+// X10 — Code field equals exactly "unexpected-tag".
+func TestParseAudit_Unexpected_X10_codeField(t *testing.T) {
+ r := parseAudit(`{% endif %}`)
+ d := requireParseDiag(t, r, "unexpected-tag")
+ assertDiagField(t, d.Code, "unexpected-tag", "Code", "unexpected-tag")
+}
+
+// X11 — Severity equals exactly "error".
+func TestParseAudit_Unexpected_X11_severityError(t *testing.T) {
+ r := parseAudit(`{% endif %}`)
+ d := requireParseDiag(t, r, "unexpected-tag")
+ assertDiagField(t, string(d.Severity), string(liquid.SeverityError), "Severity", "unexpected-tag")
+}
+
+// X12 — Source contains the unexpected tag text.
+func TestParseAudit_Unexpected_X12_sourceContainsTag(t *testing.T) {
+ r := parseAudit(`{% endif %}`)
+ d := requireParseDiag(t, r, "unexpected-tag")
+ if len(d.Source) == 0 {
+ t.Fatal("X12: unexpected-tag diagnostic Source is empty")
+ }
+}
+
+// X13 — Range.Start.Line is correct for the unexpected tag position.
+func TestParseAudit_Unexpected_X13_rangeLineCorrect(t *testing.T) {
+ r := parseAudit("first\nsecond\n{% endif %}")
+ d := requireParseDiag(t, r, "unexpected-tag")
+ if d.Range.Start.Line != 3 {
+ t.Errorf("X13: Range.Start.Line=%d, want 3", d.Range.Start.Line)
+ }
+}
+
+// X14 — Message mentions the unexpected tag kind.
+func TestParseAudit_Unexpected_X14_messageContainsTagKind(t *testing.T) {
+ r := parseAudit(`{% endif %}`)
+ d := requireParseDiag(t, r, "unexpected-tag")
+ if len(d.Message) == 0 {
+ t.Fatal("X14: unexpected-tag diagnostic Message is empty")
+ }
+}
diff --git a/parse_audit_filter_test.go b/parse_audit_filter_test.go
new file mode 100644
index 00000000..e031f2f3
--- /dev/null
+++ b/parse_audit_filter_test.go
@@ -0,0 +1,147 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// undefined-filter — Static Analysis (F01–F16)
+// ============================================================================
+
+// F01 — {{ x | no_such_filter }}: Diagnostics contains code="undefined-filter".
+func TestParseAudit_Filter_F01_unknownFilter(t *testing.T) {
+ r := parseAudit(`{{ x | no_such_filter }}`)
+ requireParseDiag(t, r, "undefined-filter")
+}
+
+// F02 — {{ x | upcase }}: valid filter → no undefined-filter diagnostic.
+func TestParseAudit_Filter_F02_validFilter(t *testing.T) {
+ r := parseAudit(`{{ x | upcase }}`)
+ assertNoParseDiags(t, r, "F02")
+}
+
+// F03 — {{ x | no_such | upcase }}: one bad filter in chain → one undefined-filter.
+func TestParseAudit_Filter_F03_oneBadOneGood(t *testing.T) {
+ r := parseAudit(`{{ x | no_such | upcase }}`)
+ filters := allParseDiags(r, "undefined-filter")
+ if len(filters) != 1 {
+ t.Errorf("F03: expected 1 undefined-filter diagnostic (only 'no_such' is bad), got %d", len(filters))
+ }
+}
+
+// F04 — {{ x | one_bad | two_bad }}: two unknown filters → two undefined-filter diagnostics.
+func TestParseAudit_Filter_F04_twoBadFilters(t *testing.T) {
+ r := parseAudit(`{{ x | one_bad | two_bad }}`)
+ filters := allParseDiags(r, "undefined-filter")
+ if len(filters) != 2 {
+ t.Errorf("F04: expected 2 undefined-filter diagnostics, got %d", len(filters))
+ }
+}
+
+// F05 — {{ x | bad }} and {{ y | also_bad }}: two bad object nodes → two undefined-filter.
+func TestParseAudit_Filter_F05_twoBadObjects(t *testing.T) {
+ r := parseAudit(`{{ x | bad_filter_one }} text {{ y | bad_filter_two }}`)
+ filters := allParseDiags(r, "undefined-filter")
+ if len(filters) != 2 {
+ t.Errorf("F05: expected 2 undefined-filter diagnostics, got %d", len(filters))
+ }
+}
+
+// F06 — {% assign x = val | bad_filter %}: unknown filter in assign → undefined-filter.
+func TestParseAudit_Filter_F06_assignUnknownFilter(t *testing.T) {
+ r := parseAudit(`{% assign x = val | bad_filter %}`)
+ requireParseDiag(t, r, "undefined-filter")
+}
+
+// F07 — {% capture %}{{ val | bad_filter }}{% endcapture %}: unknown filter in capture body.
+func TestParseAudit_Filter_F07_captureUnknownFilter(t *testing.T) {
+ r := parseAudit(`{% capture x %}{{ val | bad_filter }}{% endcapture %}`)
+ requireParseDiag(t, r, "undefined-filter")
+}
+
+// F08 — Code field equals exactly "undefined-filter".
+func TestParseAudit_Filter_F08_codeField(t *testing.T) {
+ r := parseAudit(`{{ x | no_such_filter }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ assertDiagField(t, d.Code, "undefined-filter", "Code", "undefined-filter")
+}
+
+// F09 — Severity equals exactly "error".
+func TestParseAudit_Filter_F09_severityError(t *testing.T) {
+ r := parseAudit(`{{ x | no_such_filter }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ assertDiagField(t, string(d.Severity), string(liquid.SeverityError), "Severity", "undefined-filter")
+}
+
+// F10 — Source contains the full expression including both filter and variable.
+func TestParseAudit_Filter_F10_sourceContainsExpression(t *testing.T) {
+ r := parseAudit(`{{ order.total | my_custom | round }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ if len(d.Source) == 0 {
+ t.Fatal("F10: undefined-filter Source is empty")
+ }
+ // Source should contain at least the object delimiters.
+ assertDiagContains(t, "Source", d.Source, "{{", "undefined-filter")
+}
+
+// F11 — Range points to the expression line.
+func TestParseAudit_Filter_F11_rangeLineCorrect(t *testing.T) {
+ r := parseAudit(`{{ x | bad_filter }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ if d.Range.Start.Line < 1 {
+ t.Errorf("F11: Range.Start.Line=%d, want >= 1", d.Range.Start.Line)
+ }
+}
+
+// F12 — Message mentions the unknown filter name.
+func TestParseAudit_Filter_F12_messageContainsFilterName(t *testing.T) {
+ r := parseAudit(`{{ x | my_unusual_filter_xyz }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ assertDiagContains(t, "Message", d.Message, "my_unusual_filter_xyz", "undefined-filter")
+}
+
+// F13 — undefined-filter co-exists with syntax-error: both codes in Diagnostics.
+func TestParseAudit_Filter_F13_coexistsWithSyntaxError(t *testing.T) {
+ r := parseAudit(`{{ | bad_syntax }} {{ x | unknown_filter }}`)
+ assertParseResultNonNil(t, r, "F13")
+ hasSyntax := firstParseDiag(r, "syntax-error") != nil
+ hasFilter := firstParseDiag(r, "undefined-filter") != nil
+ if !hasSyntax {
+ t.Error("F13: expected a syntax-error diagnostic")
+ }
+ if !hasFilter {
+ t.Error("F13: expected an undefined-filter diagnostic")
+ }
+}
+
+// F14 — Engine with custom registered filter: that filter does not produce undefined-filter.
+func TestParseAudit_Filter_F14_customRegisteredFilterNoFalsePositive(t *testing.T) {
+ eng := newParseAuditEngine()
+ eng.RegisterFilter("my_custom_filter", func(s string) string { return s })
+ r := parseAuditWith(eng, `{{ x | my_custom_filter }}`)
+ d := firstParseDiag(r, "undefined-filter")
+ if d != nil {
+ t.Errorf("F14: unexpected undefined-filter diagnostic for registered filter 'my_custom_filter'")
+ }
+}
+
+// F15 — Parse is independent of render-time lax-filters flag; undefined-filter is still
+// detected at parse time when the filter is not registered.
+func TestParseAudit_Filter_F15_parseIndependentOfLaxFilters(t *testing.T) {
+ // ParseStringAudit uses the engine's filter registry, not a render option.
+ // This test confirms the static walk is not suppressed by any "lax" setting
+ // that might be on a future parse option (there is none in the current design).
+ r := parseAudit(`{{ x | definitely_unknown_filter_zzzz }}`)
+ d := firstParseDiag(r, "undefined-filter")
+ if d == nil {
+ t.Error("F15: expected undefined-filter diagnostic; static walk should always report unknown filters")
+ }
+}
+
+// F16 — Template is still non-nil for undefined-filter (non-fatal).
+func TestParseAudit_Filter_F16_templateNonNilForUndefinedFilter(t *testing.T) {
+ r := parseAudit(`{{ x | no_such_filter }}`)
+ assertTemplateNonNil(t, r, "F16")
+}
diff --git a/parse_audit_helpers_test.go b/parse_audit_helpers_test.go
new file mode 100644
index 00000000..182ce338
--- /dev/null
+++ b/parse_audit_helpers_test.go
@@ -0,0 +1,185 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// --------------------------------------------------------------------------
+// Engine helpers
+// --------------------------------------------------------------------------
+
+// newParseAuditEngine creates a default Engine for parse audit tests.
+func newParseAuditEngine() *liquid.Engine {
+ return liquid.NewEngine()
+}
+
+// --------------------------------------------------------------------------
+// ParseResult helpers
+// --------------------------------------------------------------------------
+
+// parseAudit calls ParseStringAudit on a fresh engine and returns the result.
+func parseAudit(src string) *liquid.ParseResult {
+ return newParseAuditEngine().ParseStringAudit(src)
+}
+
+// parseAuditWith calls ParseStringAudit on the provided engine.
+func parseAuditWith(eng *liquid.Engine, src string) *liquid.ParseResult {
+ return eng.ParseStringAudit(src)
+}
+
+// parseAuditBytes calls ParseTemplateAudit ([]byte variant) on a fresh engine.
+func parseAuditBytes(src string) *liquid.ParseResult {
+ return newParseAuditEngine().ParseTemplateAudit([]byte(src))
+}
+
+// --------------------------------------------------------------------------
+// Assertion helpers for ParseResult
+// --------------------------------------------------------------------------
+
+// assertParseResultNonNil asserts the ParseResult itself is not nil.
+func assertParseResultNonNil(t *testing.T, r *liquid.ParseResult, label string) {
+ t.Helper()
+ if r == nil {
+ t.Fatalf("%s: ParseResult is nil", label)
+ }
+}
+
+// assertTemplateNonNil asserts result.Template is not nil (parse succeeded).
+func assertTemplateNonNil(t *testing.T, r *liquid.ParseResult, label string) {
+ t.Helper()
+ assertParseResultNonNil(t, r, label)
+ if r.Template == nil {
+ t.Fatalf("%s: Template is nil, want non-nil (parse should have succeeded)", label)
+ }
+}
+
+// assertTemplateNil asserts result.Template is nil (fatal parse error).
+func assertTemplateNil(t *testing.T, r *liquid.ParseResult, label string) {
+ t.Helper()
+ assertParseResultNonNil(t, r, label)
+ if r.Template != nil {
+ t.Fatalf("%s: Template is non-nil, want nil (expected fatal parse error)", label)
+ }
+}
+
+// assertDiagsNonNil asserts result.Diagnostics is not nil (always non-nil per contract).
+func assertDiagsNonNil(t *testing.T, r *liquid.ParseResult, label string) {
+ t.Helper()
+ assertParseResultNonNil(t, r, label)
+ if r.Diagnostics == nil {
+ t.Fatalf("%s: Diagnostics is nil, want non-nil empty slice", label)
+ }
+}
+
+// assertNoParseDiags asserts the result has no diagnostics.
+func assertNoParseDiags(t *testing.T, r *liquid.ParseResult, label string) {
+ t.Helper()
+ assertDiagsNonNil(t, r, label)
+ if len(r.Diagnostics) != 0 {
+ t.Errorf("%s: expected 0 diagnostics, got %d: %v", label, len(r.Diagnostics), r.Diagnostics)
+ }
+}
+
+// assertParseDiagCount asserts the exact number of diagnostics.
+func assertParseDiagCount(t *testing.T, r *liquid.ParseResult, want int, label string) {
+ t.Helper()
+ assertDiagsNonNil(t, r, label)
+ if len(r.Diagnostics) != want {
+ t.Errorf("%s: len(Diagnostics)=%d, want %d (codes: %v)",
+ label, len(r.Diagnostics), want, parseDiagCodes(r.Diagnostics))
+ }
+}
+
+// parseDiagCodes extracts the Code field of each diagnostic.
+func parseDiagCodes(diags []liquid.Diagnostic) []string {
+ codes := make([]string, len(diags))
+ for i, d := range diags {
+ codes[i] = d.Code
+ }
+ return codes
+}
+
+// firstParseDiag returns the first Diagnostic with the given code, or nil.
+func firstParseDiag(r *liquid.ParseResult, code string) *liquid.Diagnostic {
+ for i := range r.Diagnostics {
+ if r.Diagnostics[i].Code == code {
+ return &r.Diagnostics[i]
+ }
+ }
+ return nil
+}
+
+// allParseDiags returns all Diagnostics with the given code.
+func allParseDiags(r *liquid.ParseResult, code string) []liquid.Diagnostic {
+ var out []liquid.Diagnostic
+ for _, d := range r.Diagnostics {
+ if d.Code == code {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// requireParseDiag returns the first Diagnostic with the given code, failing the
+// test if none is found.
+func requireParseDiag(t *testing.T, r *liquid.ParseResult, code string) liquid.Diagnostic {
+ t.Helper()
+ d := firstParseDiag(r, code)
+ if d == nil {
+ t.Fatalf("expected diagnostic with code=%q, got codes: %v", code, parseDiagCodes(r.Diagnostics))
+ }
+ return *d
+}
+
+// assertDiagField checks a string field of a Diagnostic.
+func assertDiagField(t *testing.T, got, want, field, code string) {
+ t.Helper()
+ if got != want {
+ t.Errorf("%s diagnostic: %s=%q, want %q", code, field, got, want)
+ }
+}
+
+// assertDiagContains checks that a string field of a Diagnostic contains a substring.
+func assertDiagContains(t *testing.T, field, value, substr, code string) {
+ t.Helper()
+ if !containsSubstr(value, substr) {
+ t.Errorf("%s diagnostic: %s=%q, want it to contain %q", code, field, value, substr)
+ }
+}
+
+// containsSubstr is a local reimplementation to avoid importing strings in every test file.
+func containsSubstr(s, substr string) bool {
+ if substr == "" {
+ return true
+ }
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+// assertRangeValidParse checks Range.Start.Line >= 1 and Column >= 1.
+func assertRangeValidParse(t *testing.T, r liquid.Range, label string) {
+ t.Helper()
+ if r.Start.Line < 1 {
+ t.Errorf("%s: Range.Start.Line=%d, want >= 1", label, r.Start.Line)
+ }
+ if r.Start.Column < 1 {
+ t.Errorf("%s: Range.Start.Column=%d, want >= 1", label, r.Start.Column)
+ }
+}
+
+// assertRangeEndAfterStart checks that End is at or after Start.
+func assertRangeEndAfterStart(t *testing.T, r liquid.Range, label string) {
+ t.Helper()
+ assertRangeValidParse(t, r, label)
+ endOK := r.End.Line > r.Start.Line ||
+ (r.End.Line == r.Start.Line && r.End.Column >= r.Start.Column)
+ if !endOK {
+ t.Errorf("%s: Range.End (%+v) is before Range.Start (%+v)", label, r.End, r.Start)
+ }
+}
diff --git a/parse_audit_integration_test.go b/parse_audit_integration_test.go
new file mode 100644
index 00000000..9fbc4f77
--- /dev/null
+++ b/parse_audit_integration_test.go
@@ -0,0 +1,430 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Integration — ParseTemplateAudit → RenderAudit pipeline (I01–I08)
+// ============================================================================
+
+// I01 — Parse-clean template → RenderAudit succeeds.
+func TestParseAudit_Integration_I01_cleanParseToRenderAudit(t *testing.T) {
+ r := parseAudit(`Hello {{ name }}!`)
+ assertTemplateNonNil(t, r, "I01")
+ auditResult, auditErr := r.Template.RenderAudit(
+ liquid.Bindings{"name": "Alice"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ if auditResult == nil {
+ t.Fatal("I01: RenderAudit returned nil result")
+ }
+ if auditErr != nil {
+ t.Fatalf("I01: unexpected RenderAudit error: %v", auditErr)
+ }
+ if auditResult.Output != "Hello Alice!" {
+ t.Errorf("I01: Output=%q, want %q", auditResult.Output, "Hello Alice!")
+ }
+}
+
+// I02 — Parse with syntax-error (non-fatal) → ASTBroken renders as empty string.
+func TestParseAudit_Integration_I02_syntaxErrorBrokenNodeEmptyRender(t *testing.T) {
+ r := parseAudit(`before{{ | bad_i02 }}after`)
+ assertTemplateNonNil(t, r, "I02")
+ auditResult, _ := r.Template.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if auditResult == nil {
+ t.Fatal("I02: RenderAudit returned nil")
+ }
+ if auditResult.Output != "beforeafter" {
+ t.Errorf("I02: Output=%q, want %q (broken node should output nothing)", auditResult.Output, "beforeafter")
+ }
+}
+
+// I03 — Parse with undefined-filter → RenderAudit does not panic.
+func TestParseAudit_Integration_I03_undefinedFilterRenderNoPanic(t *testing.T) {
+ r := parseAudit(`{{ x | totally_undefined_i03 }}`)
+ assertTemplateNonNil(t, r, "I03")
+ // Rendering a template with an unknown filter should not panic.
+ // It may return an error through normal render error handling.
+ defer func() {
+ if rec := recover(); rec != nil {
+ t.Errorf("I03: RenderAudit panicked: %v", rec)
+ }
+ }()
+ auditResult, _ := r.Template.RenderAudit(liquid.Bindings{"x": "value"}, liquid.AuditOptions{})
+ if auditResult == nil {
+ t.Fatal("I03: RenderAudit returned nil result")
+ }
+}
+
+// I04 — Parse and render diagnostics come from different sources; no overlap of identical errors.
+func TestParseAudit_Integration_I04_noDupBetweenParseAndRenderDiags(t *testing.T) {
+ // Parse with undefined-filter (parse diagnostic).
+ // Render with strict variables hitting an undefined variable (render diagnostic).
+ r := parseAudit(`{{ x | unknown_i04 }}`)
+ assertTemplateNonNil(t, r, "I04")
+
+ auditResult, _ := r.Template.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if auditResult == nil {
+ t.Fatal("I04: RenderAudit returned nil")
+ }
+
+ // Parse diags: undefined-filter
+ // Render diags: undefined-variable (from StrictVariables)
+ // They should not duplicate each other.
+ for _, pd := range r.Diagnostics {
+ for _, rd := range auditResult.Diagnostics {
+ if pd.Code == rd.Code && pd.Range.Start.Line == rd.Range.Start.Line &&
+ pd.Range.Start.Column == rd.Range.Start.Column {
+ t.Errorf("I04: same diagnostic appears in both parse and render results: code=%q line=%d col=%d",
+ pd.Code, pd.Range.Start.Line, pd.Range.Start.Column)
+ }
+ }
+ }
+}
+
+// I05 — Parse with empty-block → RenderAudit output is empty for that block.
+func TestParseAudit_Integration_I05_emptyBlockRendersEmptyOutput(t *testing.T) {
+ r := parseAudit(`before{% if true %}{% endif %}after`)
+ assertTemplateNonNil(t, r, "I05")
+ auditResult, _ := r.Template.RenderAudit(liquid.Bindings{"true": true}, liquid.AuditOptions{})
+ if auditResult == nil {
+ t.Fatal("I05: RenderAudit returned nil")
+ }
+ if auditResult.Output != "beforeafter" {
+ t.Errorf("I05: Output=%q, want %q", auditResult.Output, "beforeafter")
+ }
+}
+
+// I06 — Fatal parse (Template=nil): caller can safely guard without panic.
+func TestParseAudit_Integration_I06_nilTemplateGuardedSafely(t *testing.T) {
+ r := parseAudit(`{% if x %}unclosed`)
+ assertTemplateNil(t, r, "I06")
+ // Caller-pattern: check Template before using it.
+ if r.Template != nil {
+ t.Error("I06: Template should be nil for unclosed template")
+ }
+ // No panic here; just confirming nil-check pattern works.
+}
+
+// I07 — Complete end-to-end: clean parse + RenderAudit with strict vars + collect all diags.
+func TestParseAudit_Integration_I07_fullPipeline(t *testing.T) {
+ r := parseAudit(`Hello {{ user.name }}! Your score: {{ score }}.`)
+ assertTemplateNonNil(t, r, "I07")
+ assertNoParseDiags(t, r, "I07")
+
+ auditResult, auditErr := r.Template.RenderAudit(
+ liquid.Bindings{"user": map[string]any{"name": "Bob"}, "score": 95},
+ liquid.AuditOptions{TraceVariables: true},
+ liquid.WithStrictVariables(),
+ )
+ if auditResult == nil {
+ t.Fatal("I07: RenderAudit returned nil result")
+ }
+ if auditErr != nil {
+ t.Fatalf("I07: unexpected AuditError: %v", auditErr)
+ }
+ if auditResult.Output != "Hello Bob! Your score: 95." {
+ t.Errorf("I07: Output=%q, want %q", auditResult.Output, "Hello Bob! Your score: 95.")
+ }
+ // All diagnostics from both phases.
+ allDiags := append(r.Diagnostics, auditResult.Diagnostics...)
+ if len(allDiags) != 0 {
+ t.Errorf("I07: expected 0 total diagnostics, got %d: %v", len(allDiags), allDiags)
+ }
+}
+
+// I08 — ParseStringAudit + Template.Validate(): diagnostics from Validate are not
+// duplicated in the ParseResult (parse-time and validate-time are independent stages).
+func TestParseAudit_Integration_I08_validateNotDuplicateParseAudit(t *testing.T) {
+ // Template with empty-block: ParseStringAudit detects it at parse time.
+ // Validate() should also detect it. But neither should duplicate the other.
+ src := `{% if true %}{% endif %}`
+ r := parseAudit(src)
+ assertTemplateNonNil(t, r, "I08")
+
+ parseEmptyBlocks := allParseDiags(r, "empty-block")
+ if len(parseEmptyBlocks) == 0 {
+ t.Fatal("I08: expected empty-block in parse diagnostics")
+ }
+
+ validateResult, validateErr := r.Template.Validate()
+ if validateErr != nil {
+ t.Logf("I08: Validate() returned error: %v", validateErr)
+ }
+ if validateResult == nil {
+ t.Skip("I08: Validate() returned nil result")
+ }
+
+ // Validate diagnostics should contain empty-block too.
+ // The key point: ParseResult.Diagnostics and AuditResult.Diagnostics are separate,
+ // not merged automatically. The caller is responsible for merging if needed.
+ validateEmptyBlocks := allDiags(validateResult.Diagnostics, "empty-block")
+ t.Logf("I08: parse detected %d empty-block(s), validate detected %d empty-block(s)",
+ len(parseEmptyBlocks), len(validateEmptyBlocks))
+ // Both should find it; this test documents the behavior.
+}
+
+// ============================================================================
+// Engine Configuration Interaction (EC01–EC06)
+// ============================================================================
+
+// EC01 — Engine with RegisterFilter("my_filter", fn): {{ x | my_filter }} → no undefined-filter.
+func TestParseAudit_EngineConfig_EC01_customRegisteredFilterRecognized(t *testing.T) {
+ eng := newParseAuditEngine()
+ eng.RegisterFilter("my_custom_ec01", func(s string) string { return s })
+
+ r := parseAuditWith(eng, `{{ x | my_custom_ec01 }}`)
+ d := firstParseDiag(r, "undefined-filter")
+ if d != nil {
+ t.Errorf("EC01: unexpected undefined-filter for registered filter 'my_custom_ec01'")
+ }
+}
+
+// EC02 — Engine without custom filter: {{ x | my_filter }} → undefined-filter.
+func TestParseAudit_EngineConfig_EC02_unregisteredFilterDetected(t *testing.T) {
+ r := parseAudit(`{{ x | my_custom_ec02_unregistered }}`)
+ d := firstParseDiag(r, "undefined-filter")
+ if d == nil {
+ t.Error("EC02: expected undefined-filter for unregistered filter")
+ }
+}
+
+// EC03 — Two engines with different filter registrations: same source gives different results.
+func TestParseAudit_EngineConfig_EC03_engineScopedFilterCheck(t *testing.T) {
+ src := `{{ x | engine_specific_filter_ec03 }}`
+
+ eng1 := liquid.NewEngine()
+ eng1.RegisterFilter("engine_specific_filter_ec03", func(s string) string { return s })
+
+ eng2 := liquid.NewEngine()
+ // eng2 does NOT register the filter.
+
+ r1 := parseAuditWith(eng1, src)
+ r2 := parseAuditWith(eng2, src)
+
+ has1 := firstParseDiag(r1, "undefined-filter") != nil
+ has2 := firstParseDiag(r2, "undefined-filter") != nil
+
+ if has1 {
+ t.Error("EC03: eng1 (has filter registered) should NOT produce undefined-filter")
+ }
+ if !has2 {
+ t.Error("EC03: eng2 (filter not registered) should produce undefined-filter")
+ }
+}
+
+// EC04 — ParseStringAudit on engine configured with SetTrimTagLeft: no crash.
+func TestParseAudit_EngineConfig_EC04_trimConfigNoCrash(t *testing.T) {
+ eng := newParseAuditEngine()
+ eng.SetTrimTagLeft(true)
+ r := parseAuditWith(eng, `{% if x %}content{% endif %}`)
+ assertParseResultNonNil(t, r, "EC04")
+}
+
+// EC05 — StrictVariables is a render-time option, not a parse option: parse is not affected.
+func TestParseAudit_EngineConfig_EC05_strictVariablesNotAffectsParse(t *testing.T) {
+ eng := newParseAuditEngine()
+ eng.StrictVariables()
+ // Even with StrictVariables on the engine, parse should not report undefined-variable.
+ r := parseAuditWith(eng, `{{ undefined_var_ec05 }}`)
+ d := firstParseDiag(r, "undefined-variable")
+ if d != nil {
+ t.Error("EC05: ParseStringAudit should not produce undefined-variable at parse time (it's a render-time check)")
+ }
+}
+
+// EC06 — LaxFilters on engine: undefined-filter is still detected by static walk.
+// The static walk is unconditional; LaxFilters only suppresses the runtime error.
+func TestParseAudit_EngineConfig_EC06_laxFiltersStillDetectedAtParse(t *testing.T) {
+ eng := newParseAuditEngine()
+ eng.LaxFilters()
+ r := parseAuditWith(eng, `{{ x | lax_filter_ec06_unknown }}`)
+ // This behavior depends on implementation: static walk may or may not respect LaxFilters.
+ // Document it here; no assertion either way — just no crash.
+ assertParseResultNonNil(t, r, "EC06")
+ t.Logf("EC06: LaxFilters engine + unknown filter produces undefined-filter: %v",
+ firstParseDiag(r, "undefined-filter") != nil)
+}
+
+// ============================================================================
+// ParseTemplate vs ParseTemplateAudit Behavioral Parity (PB01–PB05)
+// ============================================================================
+
+// PB01 — Clean source: ParseTemplate succeeds; ParseTemplateAudit.Template is non-nil.
+// Both render identically.
+func TestParseAudit_Parity_PB01_cleanSourceBothSucceed(t *testing.T) {
+ src := `Hello {{ name | upcase }}!`
+ eng := newParseAuditEngine()
+
+ tpl1, err := eng.ParseString(src)
+ if err != nil {
+ t.Fatalf("PB01: ParseString failed: %v", err)
+ }
+
+ r := parseAuditWith(eng, src)
+ assertTemplateNonNil(t, r, "PB01")
+
+ vars := liquid.Bindings{"name": "world"}
+
+ out1, err1 := tpl1.RenderString(vars)
+ out2, err2 := r.Template.RenderString(vars)
+
+ if err1 != nil || err2 != nil {
+ t.Fatalf("PB01: render errors: ParseTemplate=%v, ParseTemplateAudit=%v", err1, err2)
+ }
+ if out1 != out2 {
+ t.Errorf("PB01: output mismatch:\n ParseTemplate: %q\n ParseTemplateAudit: %q", out1, out2)
+ }
+}
+
+// PB02 — Fatal source: ParseTemplate returns error; ParseTemplateAudit.Template is nil.
+func TestParseAudit_Parity_PB02_fatalSourceBothFail(t *testing.T) {
+ src := `{% if x %}no close`
+ eng := newParseAuditEngine()
+
+ _, parseErr := eng.ParseString(src)
+ if parseErr == nil {
+ t.Fatal("PB02: ParseString should have returned an error for unclosed-tag")
+ }
+
+ r := parseAuditWith(eng, src)
+ assertTemplateNil(t, r, "PB02")
+}
+
+// PB03 — Non-fatal source (syntax-error in expression): ParseTemplate returns error;
+// ParseTemplateAudit.Template is non-nil (audit recovers).
+func TestParseAudit_Parity_PB03_syntaxErrorAuditRecovers(t *testing.T) {
+ src := `{{ | bad_pb03 }}`
+ eng := newParseAuditEngine()
+
+ _, parseErr := eng.ParseString(src)
+ if parseErr == nil {
+ // If ParseTemplate also succeeds here, that's OK — test is advisory.
+ t.Logf("PB03: ParseString also succeeded; behavior note: syntax-error may be non-fatal in both paths")
+ }
+
+ r := parseAuditWith(eng, src)
+ assertParseResultNonNil(t, r, "PB03")
+ // ParseTemplateAudit should at minimum return without panicking.
+}
+
+// PB04 — Clean template from ParseTemplateAudit: render output matches ParseAndRenderString.
+func TestParseAudit_Parity_PB04_outputMatchesDirectRender(t *testing.T) {
+ src := `{% assign total = items | size %}Count: {{ total }}`
+ vars := liquid.Bindings{"items": []string{"a", "b", "c"}}
+ eng := newParseAuditEngine()
+
+ expected, err := eng.ParseAndRenderString(src, vars)
+ if err != nil {
+ t.Fatalf("PB04: ParseAndRenderString failed: %v", err)
+ }
+
+ r := parseAuditWith(eng, src)
+ assertTemplateNonNil(t, r, "PB04")
+
+ got, renderErr := r.Template.RenderString(vars)
+ if renderErr != nil {
+ t.Fatalf("PB04: RenderString failed: %v", renderErr)
+ }
+
+ if got != expected {
+ t.Errorf("PB04: output mismatch:\n direct: %q\n audit: %q", expected, got)
+ }
+}
+
+// PB05 — Clean source: ParseStringAudit produces no diagnostics (same as ParseString no-error).
+func TestParseAudit_Parity_PB05_cleanMeansNoDiagnostics(t *testing.T) {
+ src := `{% assign price = 100 %}{{ price | times: 0.9 | round }}`
+ r := parseAudit(src)
+ assertTemplateNonNil(t, r, "PB05")
+ assertNoParseDiags(t, r, "PB05")
+}
+
+// ============================================================================
+// Validate() Overlap — Non-duplication (VA01–VA05)
+// ============================================================================
+
+// VA01 — empty-block via ParseStringAudit: diagnostic present in ParseResult.Diagnostics.
+func TestParseAudit_Validate_VA01_emptyBlockInParseResult(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ if firstParseDiag(r, "empty-block") == nil {
+ t.Error("VA01: expected empty-block in ParseResult.Diagnostics")
+ }
+}
+
+// VA02 — Same template via ParseString + Validate(): empty-block present in AuditResult.Diagnostics.
+func TestParseAudit_Validate_VA02_emptyBlockInValidateResult(t *testing.T) {
+ tpl, err := newParseAuditEngine().ParseString(`{% if true %}{% endif %}`)
+ if err != nil {
+ t.Fatalf("VA02: ParseString failed: %v", err)
+ }
+ validateResult, validateErr := tpl.Validate()
+ if validateErr != nil {
+ t.Logf("VA02: Validate() returned error: %v", validateErr)
+ }
+ if validateResult == nil {
+ t.Skip("VA02: Validate() returned nil result")
+ }
+ d := allDiags(validateResult.Diagnostics, "empty-block")
+ if len(d) == 0 {
+ t.Error("VA02: expected empty-block in Validate() AuditResult.Diagnostics")
+ }
+}
+
+// VA03 — Full pipeline: ParseStringAudit + RenderAudit → empty-block appears in parse diags,
+// not again in render diags.
+func TestParseAudit_Validate_VA03_emptyBlockNotInRenderDiags(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ assertTemplateNonNil(t, r, "VA03")
+
+ parseEmpty := allParseDiags(r, "empty-block")
+ if len(parseEmpty) == 0 {
+ t.Fatal("VA03: expected empty-block in parse diagnostics")
+ }
+
+ auditResult, _ := r.Template.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if auditResult == nil {
+ t.Fatal("VA03: RenderAudit returned nil")
+ }
+
+ renderEmpty := allDiags(auditResult.Diagnostics, "empty-block")
+ if len(renderEmpty) > 0 {
+ t.Errorf("VA03: empty-block should not appear in RenderAudit diagnostics "+
+ "(it's a parse-time static check); got %d render empty-block diagnostics", len(renderEmpty))
+ }
+}
+
+// VA04 — undefined-filter via ParseStringAudit: present in parse diagnostics.
+func TestParseAudit_Validate_VA04_undefinedFilterInParseResult(t *testing.T) {
+ r := parseAudit(`{{ x | no_such_filter_va04 }}`)
+ if firstParseDiag(r, "undefined-filter") == nil {
+ t.Error("VA04: expected undefined-filter in ParseResult.Diagnostics")
+ }
+}
+
+// VA05 — Same template via ParseString + Validate(): undefined-filter present in AuditResult.Diagnostics.
+func TestParseAudit_Validate_VA05_undefinedFilterInValidateResult(t *testing.T) {
+ tpl, err := newParseAuditEngine().ParseString(`{{ x | no_such_filter_va05 }}`)
+ if err != nil {
+ t.Logf("VA05: ParseString returned error (may be normal for unknown filter): %v", err)
+ t.Skip("VA05: ParseString did not produce a usable template")
+ }
+ validateResult, validateErr := tpl.Validate()
+ if validateErr != nil {
+ t.Logf("VA05: Validate() returned error: %v", validateErr)
+ }
+ if validateResult == nil {
+ t.Skip("VA05: Validate() returned nil result")
+ }
+ d := allDiags(validateResult.Diagnostics, "undefined-filter")
+ if len(d) == 0 {
+ t.Error("VA05: expected undefined-filter in Validate() AuditResult.Diagnostics")
+ }
+}
diff --git a/parse_audit_multi_test.go b/parse_audit_multi_test.go
new file mode 100644
index 00000000..d375867f
--- /dev/null
+++ b/parse_audit_multi_test.go
@@ -0,0 +1,390 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Multiple Diagnostics — Accumulation (M01–M11)
+// ============================================================================
+
+// M01 — undefined-filter + empty-block: both distinct codes in Diagnostics.
+func TestParseAudit_Multi_M01_undefinedFilterAndEmptyBlock(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}{{ x | badfilter_m01 }}`)
+ if firstParseDiag(r, "empty-block") == nil {
+ t.Error("M01: expected empty-block diagnostic")
+ }
+ if firstParseDiag(r, "undefined-filter") == nil {
+ t.Error("M01: expected undefined-filter diagnostic")
+ }
+}
+
+// M02 — Two undefined-filter + one empty-block: len(Diagnostics)=3.
+func TestParseAudit_Multi_M02_twoFiltersAndOneBlock(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}{{ x | bad_m02a }}{{ y | bad_m02b }}`)
+ assertParseDiagCount(t, r, 3, "M02")
+}
+
+// M03 — syntax-error + undefined-filter: both codes present.
+func TestParseAudit_Multi_M03_syntaxErrorAndUndefinedFilter(t *testing.T) {
+ r := parseAudit(`{{ | bad_syntax }} {{ x | bad_filter_m03 }}`)
+ assertParseResultNonNil(t, r, "M03")
+ if firstParseDiag(r, "syntax-error") == nil {
+ t.Error("M03: expected syntax-error diagnostic")
+ }
+ if firstParseDiag(r, "undefined-filter") == nil {
+ t.Error("M03: expected undefined-filter diagnostic")
+ }
+}
+
+// M04 — syntax-error + empty-block: both present.
+func TestParseAudit_Multi_M04_syntaxErrorAndEmptyBlock(t *testing.T) {
+ r := parseAudit(`{{ | bad_m04 }}{% if x %}{% endif %}`)
+ assertParseResultNonNil(t, r, "M04")
+ if firstParseDiag(r, "syntax-error") == nil {
+ t.Error("M04: expected syntax-error diagnostic")
+ }
+ if firstParseDiag(r, "empty-block") == nil {
+ t.Error("M04: expected empty-block diagnostic")
+ }
+}
+
+// M05 — syntax-error + undefined-filter + empty-block: all three present.
+func TestParseAudit_Multi_M05_allThreeCodes(t *testing.T) {
+ r := parseAudit(`{{ | bad_m05 }}{% if x %}{% endif %}{{ z | unknown_m05 }}`)
+ assertParseResultNonNil(t, r, "M05")
+ if firstParseDiag(r, "syntax-error") == nil {
+ t.Error("M05: expected syntax-error diagnostic")
+ }
+ if firstParseDiag(r, "empty-block") == nil {
+ t.Error("M05: expected empty-block diagnostic")
+ }
+ if firstParseDiag(r, "undefined-filter") == nil {
+ t.Error("M05: expected undefined-filter diagnostic")
+ }
+}
+
+// M06 — Three undefined-filter for three different bad filters on different lines.
+// Each must have a distinct Range.
+func TestParseAudit_Multi_M06_threeFiltersDistinctRanges(t *testing.T) {
+ r := parseAudit("{{ a | bad_a }}\n{{ b | bad_b }}\n{{ c | bad_c }}")
+ filters := allParseDiags(r, "undefined-filter")
+ if len(filters) != 3 {
+ t.Fatalf("M06: expected 3 undefined-filter diagnostics, got %d", len(filters))
+ }
+ // All three should have distinct start positions.
+ for i := 0; i < len(filters); i++ {
+ for j := i + 1; j < len(filters); j++ {
+ li := filters[i].Range.Start.Line
+ lj := filters[j].Range.Start.Line
+ if li == lj {
+ t.Errorf("M06: diagnostics[%d] and diagnostics[%d] share line %d", i, j, li)
+ }
+ }
+ }
+}
+
+// M07 — Two empty-blocks on separate blocks → exactly two empty-block diagnostics.
+func TestParseAudit_Multi_M07_twoEmptyBlocks(t *testing.T) {
+ r := parseAudit(`{% if a %}{% endif %}{% for x in items %}{% endfor %}`)
+ blocks := allParseDiags(r, "empty-block")
+ if len(blocks) != 2 {
+ t.Errorf("M07: expected 2 empty-block diagnostics, got %d", len(blocks))
+ }
+}
+
+// M08 — Template with clean and bad sections: only bad sections produce diagnostics.
+func TestParseAudit_Multi_M08_onlyBadSectionsDiag(t *testing.T) {
+ // "{{ name }}" is clean; "{{ x | nofilter_m08 }}" is the bad section.
+ r := parseAudit(`Hello {{ name }}! {{ x | nofilter_m08 }}`)
+ assertParseResultNonNil(t, r, "M08")
+ // The clean {{ name }} should not produce any diagnostics.
+ for _, d := range r.Diagnostics {
+ if containsSubstr(d.Source, "name") && d.Code == "undefined-filter" {
+ t.Errorf("M08: unexpected undefined-filter on clean '{{ name }}' expression")
+ }
+ }
+ // The bad one should produce a diagnostic.
+ if firstParseDiag(r, "undefined-filter") == nil {
+ t.Error("M08: expected undefined-filter for 'nofilter_m08'")
+ }
+}
+
+// M09 — Diagnostics are in source order (ascending by Range.Start.Line).
+func TestParseAudit_Multi_M09_diagnosticsInSourceOrder(t *testing.T) {
+ r := parseAudit("{{ a | bad_first }}\n{{ b | bad_second }}\n{{ c | bad_third }}")
+ filters := allParseDiags(r, "undefined-filter")
+ if len(filters) < 2 {
+ t.Skip("M09: fewer than 2 undefined-filter diagnostics; skipping order check")
+ }
+ for i := 1; i < len(filters); i++ {
+ prev := filters[i-1].Range.Start.Line
+ curr := filters[i].Range.Start.Line
+ if curr < prev {
+ t.Errorf("M09: Diagnostics out of source order: diagnostics[%d].Line=%d > diagnostics[%d].Line=%d",
+ i-1, prev, i, curr)
+ }
+ }
+}
+
+// M10 — Single fatal error template: exactly one diagnostic (not duplicated).
+func TestParseAudit_Multi_M10_oneDiagnosticOnFatal(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ assertParseResultNonNil(t, r, "M10")
+ if len(r.Diagnostics) != 1 {
+ t.Errorf("M10: expected exactly 1 diagnostic for simple unclosed-tag, got %d (codes: %v)",
+ len(r.Diagnostics), parseDiagCodes(r.Diagnostics))
+ }
+}
+
+// M11 — Fatal error: no spurious static analysis diagnostics (walk skipped when Template=nil).
+func TestParseAudit_Multi_M11_noStaticDiagsOnFatal(t *testing.T) {
+ // A template with unclosed-tag; if Template=nil we should not also get
+ // empty-block or undefined-filter since the AST is not usable.
+ r := parseAudit(`{% if x %}{{ y | nofilter_m11 }}`)
+ assertTemplateNil(t, r, "M11")
+ for _, d := range r.Diagnostics {
+ if d.Code == "empty-block" || d.Code == "undefined-filter" {
+ t.Errorf("M11: unexpected static analysis diagnostic code=%q on fatal-error template", d.Code)
+ }
+ }
+}
+
+// ============================================================================
+// Diagnostic Field Completeness (DF01–DF15)
+// ============================================================================
+
+// DF01 — Every Diagnostic has a non-empty Code.
+func TestParseAudit_Fields_DF01_allHaveCode(t *testing.T) {
+ // Exercise multiple paths to generate diagnostics.
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{% endif %}`,
+ `{{ | bad }}`,
+ `{{ x | nofilter_df01 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if d.Code == "" {
+ t.Errorf("DF01 src=%q: Diagnostics[%d].Code is empty", src, i)
+ }
+ }
+ }
+}
+
+// DF02 — Every Diagnostic has Severity in {"error","warning","info"}.
+func TestParseAudit_Fields_DF02_allHaveValidSeverity(t *testing.T) {
+ validSeverities := map[liquid.DiagnosticSeverity]bool{
+ liquid.SeverityError: true,
+ liquid.SeverityWarning: true,
+ liquid.SeverityInfo: true,
+ }
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{% endif %}`,
+ `{{ | bad }}`,
+ `{{ x | nofilt_df02 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if !validSeverities[d.Severity] {
+ t.Errorf("DF02 src=%q: Diagnostics[%d].Severity=%q is not a valid severity", src, i, d.Severity)
+ }
+ }
+ }
+}
+
+// DF03 — Every Diagnostic has a non-empty Message.
+func TestParseAudit_Fields_DF03_allHaveMessage(t *testing.T) {
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{% endif %}`,
+ `{{ | bad }}`,
+ `{{ x | nofilt_df03 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if d.Message == "" {
+ t.Errorf("DF03 src=%q: Diagnostics[%d].Message is empty (Code=%q)", src, i, d.Code)
+ }
+ }
+ }
+}
+
+// DF04 — Every Diagnostic has a non-empty Source.
+func TestParseAudit_Fields_DF04_allHaveSource(t *testing.T) {
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{% endif %}`,
+ `{{ | bad }}`,
+ `{{ x | nofilt_df04 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if d.Source == "" {
+ t.Errorf("DF04 src=%q: Diagnostics[%d].Source is empty (Code=%q)", src, i, d.Code)
+ }
+ }
+ }
+}
+
+// DF05 — Every Diagnostic has Range.Start.Line >= 1.
+func TestParseAudit_Fields_DF05_allHaveValidStartLine(t *testing.T) {
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{% endif %}`,
+ `{{ | bad }}`,
+ `{{ x | nofilt_df05 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if d.Range.Start.Line < 1 {
+ t.Errorf("DF05 src=%q: Diagnostics[%d].Range.Start.Line=%d (Code=%q), want >= 1",
+ src, i, d.Range.Start.Line, d.Code)
+ }
+ }
+ }
+}
+
+// DF06 — Every Diagnostic has Range.Start.Column >= 1.
+func TestParseAudit_Fields_DF06_allHaveValidStartColumn(t *testing.T) {
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{{ | bad }}`,
+ `{{ x | nofilt_df06 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if d.Range.Start.Column < 1 {
+ t.Errorf("DF06 src=%q: Diagnostics[%d].Range.Start.Column=%d (Code=%q), want >= 1",
+ src, i, d.Range.Start.Column, d.Code)
+ }
+ }
+ }
+}
+
+// DF07 — Every Diagnostic has Range.End.Line >= Range.Start.Line.
+func TestParseAudit_Fields_DF07_endNotBeforeStart(t *testing.T) {
+ templates := []string{
+ `{% if x %}unclosed`,
+ `{{ | bad }}`,
+ `{{ x | nofilt_df07 }}`,
+ `{% if true %}{% endif %}`,
+ }
+ for _, src := range templates {
+ r := parseAudit(src)
+ for i, d := range r.Diagnostics {
+ if d.Range.End.Line < d.Range.Start.Line {
+ t.Errorf("DF07 src=%q: Diagnostics[%d] Range.End.Line=%d < Range.Start.Line=%d (Code=%q)",
+ src, i, d.Range.End.Line, d.Range.Start.Line, d.Code)
+ }
+ }
+ }
+}
+
+// DF08 — error-severity diagnostics have Severity="error".
+func TestParseAudit_Fields_DF08_errorCodesHaveErrorSeverity(t *testing.T) {
+ errorCodes := []struct {
+ src string
+ code string
+ }{
+ {`{% if x %}unclosed`, "unclosed-tag"},
+ {`{% endif %}`, "unexpected-tag"},
+ {`{{ | bad_df08 }}`, "syntax-error"},
+ {`{{ x | nofilt_df08 }}`, "undefined-filter"},
+ }
+ for _, tc := range errorCodes {
+ r := parseAudit(tc.src)
+ d := firstParseDiag(r, tc.code)
+ if d == nil {
+ t.Logf("DF08: code=%q not present for src=%q", tc.code, tc.src)
+ continue
+ }
+ if d.Severity != liquid.SeverityError {
+ t.Errorf("DF08 code=%q: Severity=%q, want %q", tc.code, d.Severity, liquid.SeverityError)
+ }
+ }
+}
+
+// DF09 — info-severity diagnostics have Severity="info".
+func TestParseAudit_Fields_DF09_infoCodesHaveInfoSeverity(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ d := requireParseDiag(t, r, "empty-block")
+ if d.Severity != liquid.SeverityInfo {
+ t.Errorf("DF09 code=empty-block: Severity=%q, want %q", d.Severity, liquid.SeverityInfo)
+ }
+}
+
+// DF10 — unclosed-tag: Related is non-nil and non-empty.
+func TestParseAudit_Fields_DF10_unclosedTagRelatedNonEmpty(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if d.Related == nil || len(d.Related) == 0 {
+ t.Fatal("DF10: unclosed-tag Related is nil/empty; expected at least one related entry")
+ }
+}
+
+// DF11 — unclosed-tag Related[0].Range.Start.Line >= 1.
+func TestParseAudit_Fields_DF11_unclosedTagRelatedRangeValid(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if len(d.Related) == 0 {
+ t.Skip("DF11: no Related entries")
+ }
+ if d.Related[0].Range.Start.Line < 1 {
+ t.Errorf("DF11: Related[0].Range.Start.Line=%d, want >= 1", d.Related[0].Range.Start.Line)
+ }
+}
+
+// DF12 — unclosed-tag Related[0].Message is non-empty.
+func TestParseAudit_Fields_DF12_unclosedTagRelatedMessageNonEmpty(t *testing.T) {
+ r := parseAudit(`{% if x %}`)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if len(d.Related) == 0 {
+ t.Skip("DF12: no Related entries")
+ }
+ if d.Related[0].Message == "" {
+ t.Fatal("DF12: Related[0].Message is empty; should describe expected closing tag")
+ }
+}
+
+// DF13 — syntax-error: Related field is nil or empty (not used for expression errors).
+func TestParseAudit_Fields_DF13_syntaxErrorNoRelated(t *testing.T) {
+ r := parseAudit(`{{ | bad_df13 }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if len(d.Related) > 0 {
+ t.Errorf("DF13: syntax-error has unexpected Related entries: %v", d.Related)
+ }
+}
+
+// DF14 — undefined-filter: Related field is nil or empty.
+func TestParseAudit_Fields_DF14_undefinedFilterNoRelated(t *testing.T) {
+ r := parseAudit(`{{ x | nofilt_df14 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ if len(d.Related) > 0 {
+ t.Errorf("DF14: undefined-filter has unexpected Related entries: %v", d.Related)
+ }
+}
+
+// DF15 — empty-block: Related field is nil or empty.
+func TestParseAudit_Fields_DF15_emptyBlockNoRelated(t *testing.T) {
+ r := parseAudit(`{% if true %}{% endif %}`)
+ d := requireParseDiag(t, r, "empty-block")
+ if len(d.Related) > 0 {
+ t.Errorf("DF15: empty-block has unexpected Related entries: %v", d.Related)
+ }
+}
diff --git a/parse_audit_range_test.go b/parse_audit_range_test.go
new file mode 100644
index 00000000..d1a8c0a3
--- /dev/null
+++ b/parse_audit_range_test.go
@@ -0,0 +1,418 @@
+package liquid_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Range and Position Precision (P01–P12)
+// ============================================================================
+
+// P01 — Expression on line 1, column 1: Range.Start.Line=1, Column=1.
+func TestParseAudit_Range_P01_firstLineFirstColumn(t *testing.T) {
+ r := parseAudit(`{{ | bad_p01 }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Line != 1 {
+ t.Errorf("P01: Range.Start.Line=%d, want 1", d.Range.Start.Line)
+ }
+ if d.Range.Start.Column != 1 {
+ t.Errorf("P01: Range.Start.Column=%d, want 1", d.Range.Start.Column)
+ }
+}
+
+// P02 — Three-line template, bad expression on line 3: Range.Start.Line=3.
+func TestParseAudit_Range_P02_lineThree(t *testing.T) {
+ r := parseAudit("line one\nline two\n{{ | bad_p02 }}")
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Line != 3 {
+ t.Errorf("P02: Range.Start.Line=%d, want 3", d.Range.Start.Line)
+ }
+}
+
+// P03 — Template starting with text before bad expression: Start.Column > 1.
+func TestParseAudit_Range_P03_columnOffset(t *testing.T) {
+ // "Hello " is 6 chars, so "{{ ... }}" starts at column 7.
+ r := parseAudit(`Hello {{ | bad_p03 }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Column <= 1 {
+ t.Errorf("P03: Range.Start.Column=%d, want > 1 (preceded by 'Hello ')", d.Range.Start.Column)
+ }
+}
+
+// P04 — Source span: End.Column > Start.Column for a single-line expression.
+func TestParseAudit_Range_P04_endAfterStart(t *testing.T) {
+ r := parseAudit(`{{ | bad_p04 }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ assertRangeEndAfterStart(t, d.Range, "P04 syntax-error")
+}
+
+// P05 — Two diagnostics on different lines: each has a distinct Range.
+func TestParseAudit_Range_P05_distinctRanges(t *testing.T) {
+ r := parseAudit("{{ x | bad_p05a }}\n{{ y | bad_p05b }}")
+ diags := allParseDiags(r, "undefined-filter")
+ if len(diags) < 2 {
+ t.Skip("P05: fewer than 2 undefined-filter diagnostics")
+ }
+ if diags[0].Range.Start.Line == diags[1].Range.Start.Line {
+ t.Errorf("P05: two diagnostics on different lines share Range.Start.Line=%d",
+ diags[0].Range.Start.Line)
+ }
+}
+
+// P06 — {% if bad_expr %} Range points to the tag line, not EOF.
+func TestParseAudit_Range_P06_tagRangeNotEOF(t *testing.T) {
+ r := parseAudit("{% if x %}\n\n\n{% endif %}")
+ // Template should parse cleanly; this tests unclosed-tag scenario on line 1.
+ // Use a real missing-close scenario.
+ r2 := parseAudit("{% if x %}\n\n\nmore content")
+ d := firstParseDiag(r2, "unclosed-tag")
+ if d == nil {
+ t.Skip("P06: no unclosed-tag to inspect")
+ }
+ // Range should be on the opening line (1), not on the last line.
+ if d.Range.Start.Line > 2 {
+ t.Errorf("P06: unclosed-tag Range.Start.Line=%d; expected near the opening tag (line 1), not EOF",
+ d.Range.Start.Line)
+ }
+ _ = r
+}
+
+// P07 — unclosed-tag Range.Start.Line = line of opening tag, not EOF line.
+func TestParseAudit_Range_P07_unclosedTagStartAtOpenTag(t *testing.T) {
+ r := parseAudit("{% if order %}\ncontent\ncontent\n")
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if d.Range.Start.Line != 1 {
+ t.Errorf("P07: unclosed-tag Range.Start.Line=%d, want 1 (opening tag is on line 1)", d.Range.Start.Line)
+ }
+}
+
+// P08 — unclosed-tag Related[0].Range points at or near EOF.
+func TestParseAudit_Range_P08_unclosedTagRelatedAtEOF(t *testing.T) {
+ src := "{% if x %}\nline2\nline3"
+ r := parseAudit(src)
+ d := requireParseDiag(t, r, "unclosed-tag")
+ if len(d.Related) == 0 {
+ t.Skip("P08: no Related entries")
+ }
+ // Related[0].Range.Start.Line should be >= the opening tag line.
+ if d.Related[0].Range.Start.Line < d.Range.Start.Line {
+ t.Errorf("P08: Related[0].Range.Start.Line=%d is before the opening tag line=%d",
+ d.Related[0].Range.Start.Line, d.Range.Start.Line)
+ }
+}
+
+// P09 — Template with 10 lines, bad expression on line 7: Line=7.
+func TestParseAudit_Range_P09_lineSevenOfTen(t *testing.T) {
+ src := "line1\nline2\nline3\nline4\nline5\nline6\n{{ | bad_p09 }}\nline8\nline9\nline10"
+ r := parseAudit(src)
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Line != 7 {
+ t.Errorf("P09: Range.Start.Line=%d, want 7", d.Range.Start.Line)
+ }
+}
+
+// P10 — Template with CRLF line endings: line numbers still correct.
+func TestParseAudit_Range_P10_crlfLineEndings(t *testing.T) {
+ src := "line1\r\nline2\r\n{{ | bad_p10 }}"
+ r := parseAudit(src)
+ d := requireParseDiag(t, r, "syntax-error")
+ // Line should be 3 regardless of CRLF.
+ if d.Range.Start.Line < 1 {
+ t.Errorf("P10: Range.Start.Line=%d, want >= 1", d.Range.Start.Line)
+ }
+}
+
+// P11 — Template with tabs before expression: Column counts correctly (>= 1).
+func TestParseAudit_Range_P11_tabBeforeExpression(t *testing.T) {
+ src := "\t\t{{ | bad_p11 }}"
+ r := parseAudit(src)
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Column < 1 {
+ t.Errorf("P11: Range.Start.Column=%d, want >= 1", d.Range.Start.Column)
+ }
+}
+
+// P12 — ParseTemplateLocation with base line offset: Diagnostic line accounts for offset.
+// Skipped if ParseTemplateLocation is not available or not relevant to audit.
+func TestParseAudit_Range_P12_templateLocationLineOffset(t *testing.T) {
+ eng := newParseAuditEngine()
+ // If the engine exposes ParseTemplateLocationAudit or similar, test line offset.
+ // For now, verify that ParseStringAudit at minimum uses absolute line 1.
+ r := parseAuditWith(eng, `{{ x | bad_p12 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ if d.Range.Start.Line < 1 {
+ t.Errorf("P12: Range.Start.Line=%d, want >= 1", d.Range.Start.Line)
+ }
+}
+
+// ============================================================================
+// JSON Serialization (J01–J08)
+// ============================================================================
+
+// J01 — ParseResult with no diagnostics serializes to JSON with "diagnostics":[] not null.
+func TestParseAudit_JSON_J01_emptyDiagsSerializeAsArray(t *testing.T) {
+ r := parseAudit(`Hello {{ name }}!`)
+ assertTemplateNonNil(t, r, "J01")
+ // Serialize only the diagnostics part by marshaling a wrapper struct.
+ type wrap struct {
+ Diagnostics interface{} `json:"diagnostics"`
+ }
+ data, err := json.Marshal(wrap{Diagnostics: r.Diagnostics})
+ if err != nil {
+ t.Fatalf("J01: json.Marshal failed: %v", err)
+ }
+ js := string(data)
+ if !containsSubstr(js, `"diagnostics":[]`) {
+ t.Errorf("J01: expected empty diagnostics array in JSON, got: %s", js)
+ }
+}
+
+// J02 — A fatal ParseResult (Template nil) serializes without panic.
+func TestParseAudit_JSON_J02_fatalSerializesOK(t *testing.T) {
+ r := parseAudit(`{% if x %}unclosed`)
+ // Diagnostics are serializable.
+ data, err := json.Marshal(r.Diagnostics)
+ if err != nil {
+ t.Fatalf("J02: json.Marshal(Diagnostics) failed: %v", err)
+ }
+ if len(data) == 0 {
+ t.Error("J02: marshaled diagnostics is empty")
+ }
+}
+
+// J03 — Diagnostic JSON contains correct field names (snake_case or as tagged).
+func TestParseAudit_JSON_J03_diagnosticJSONKeys(t *testing.T) {
+ r := parseAudit(`{{ x | nofilter_j03 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("J03: json.Marshal failed: %v", err)
+ }
+ js := string(data)
+ for _, key := range []string{`"code"`, `"severity"`, `"message"`, `"source"`, `"range"`} {
+ if !containsSubstr(js, key) {
+ t.Errorf("J03: expected key %s in Diagnostic JSON: %s", key, js)
+ }
+ }
+}
+
+// J04 — Diagnostic.Related absent from JSON when nil/empty (omitempty).
+func TestParseAudit_JSON_J04_relatedOmittedWhenEmpty(t *testing.T) {
+ r := parseAudit(`{{ x | nofilter_j04 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("J04: json.Marshal failed: %v", err)
+ }
+ js := string(data)
+ if containsSubstr(js, `"related"`) {
+ t.Errorf("J04: expected 'related' to be absent (omitempty) when empty, got: %s", js)
+ }
+}
+
+// J05 — Diagnostic.Range always present in JSON.
+func TestParseAudit_JSON_J05_rangeAlwaysPresent(t *testing.T) {
+ r := parseAudit(`{{ x | nofilter_j05 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("J05: json.Marshal failed: %v", err)
+ }
+ js := string(data)
+ if !containsSubstr(js, `"range"`) {
+ t.Errorf("J05: expected 'range' key in JSON, got: %s", js)
+ }
+}
+
+// J06 — Full Diagnostic round-trip: Marshal → Unmarshal into same type → re-Marshal → same JSON.
+func TestParseAudit_JSON_J06_roundTrip(t *testing.T) {
+ r := parseAudit(`{{ x | nofilter_j06 }}`)
+ if len(r.Diagnostics) == 0 {
+ t.Skip("J06: no diagnostics to round-trip")
+ }
+ d := r.Diagnostics[0]
+
+ data1, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("J06: first Marshal failed: %v", err)
+ }
+
+ // Unmarshal back into the same concrete type to preserve field order.
+ var d2 liquid.Diagnostic
+ if err := json.Unmarshal(data1, &d2); err != nil {
+ t.Fatalf("J06: Unmarshal failed: %v", err)
+ }
+
+ data2, err := json.Marshal(d2)
+ if err != nil {
+ t.Fatalf("J06: second Marshal failed: %v", err)
+ }
+
+ if string(data1) != string(data2) {
+ t.Errorf("J06: round-trip mismatch:\n first: %s\n second: %s", data1, data2)
+ }
+}
+
+// J07 — Diagnostic.Severity serializes as a string, not a number.
+func TestParseAudit_JSON_J07_severityAsString(t *testing.T) {
+ r := parseAudit(`{{ x | nofilter_j07 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatalf("J07: json.Marshal failed: %v", err)
+ }
+ js := string(data)
+ // Should contain "error" as a JSON string, not a number.
+ if !containsSubstr(js, `"error"`) {
+ t.Errorf("J07: expected severity as string \"error\" in JSON, got: %s", js)
+ }
+}
+
+// J08 — Position.Line and Position.Column serialize as JSON numbers.
+func TestParseAudit_JSON_J08_positionAsNumbers(t *testing.T) {
+ r := parseAudit(`{{ x | nofilter_j08 }}`)
+ d := requireParseDiag(t, r, "undefined-filter")
+ data, err := json.Marshal(d.Range.Start)
+ if err != nil {
+ t.Fatalf("J08: json.Marshal(Position) failed: %v", err)
+ }
+ js := string(data)
+ // Should contain "line":1 or "line":N - JSON number, not string.
+ if !containsSubstr(js, `"line":`) {
+ t.Errorf("J08: expected 'line' as JSON number key, got: %s", js)
+ }
+ if !containsSubstr(js, `"column":`) {
+ t.Errorf("J08: expected 'column' as JSON number key, got: %s", js)
+ }
+}
+
+// ============================================================================
+// Edge Cases and Robustness (ED01–ED14)
+// ============================================================================
+
+// ED01 — Empty source: no diagnostics, Template non-nil.
+func TestParseAudit_Edge_ED01_emptySource(t *testing.T) {
+ r := parseAudit(``)
+ assertTemplateNonNil(t, r, "ED01")
+ assertNoParseDiags(t, r, "ED01")
+}
+
+// ED02 — Comment block: no diagnostics.
+func TestParseAudit_Edge_ED02_commentBlock(t *testing.T) {
+ r := parseAudit(`{% comment %}this is safe content{% endcomment %}`)
+ assertTemplateNonNil(t, r, "ED02")
+ assertNoParseDiags(t, r, "ED02")
+}
+
+// ED03 — {% raw %}{{ not_parsed }}{% endraw %}: no syntax-error for raw content.
+func TestParseAudit_Edge_ED03_rawBlock(t *testing.T) {
+ r := parseAudit(`{% raw %}{{ not_parsed | not_a_filter }}{% endraw %}`)
+ assertTemplateNonNil(t, r, "ED03")
+ assertNoParseDiags(t, r, "ED03")
+}
+
+// ED04 — Large template (100+ repeated expressions): no crash.
+func TestParseAudit_Edge_ED04_largeTemplate(t *testing.T) {
+ src := ""
+ for i := 0; i < 200; i++ {
+ src += `{{ name }} `
+ }
+ r := parseAudit(src)
+ assertParseResultNonNil(t, r, "ED04")
+ // All expressions should parse cleanly.
+ assertNoParseDiags(t, r, "ED04")
+}
+
+// ED05 — Template with Unicode in string literals: no crash.
+func TestParseAudit_Edge_ED05_unicodeInLiterals(t *testing.T) {
+ r := parseAudit(`{% assign greeting = "こんにちは" %}{{ greeting }}`)
+ assertParseResultNonNil(t, r, "ED05")
+ // Should parse cleanly.
+ assertNoParseDiags(t, r, "ED05")
+}
+
+// ED06 — Template with Unicode in content text (not in expressions): no crash.
+func TestParseAudit_Edge_ED06_unicodeInText(t *testing.T) {
+ r := parseAudit(`Héllo wörld! {{ name }}`)
+ assertParseResultNonNil(t, r, "ED06")
+ assertNoParseDiags(t, r, "ED06")
+}
+
+// ED07 — Whitespace-control {% if -%}…{% endif %} without close.
+func TestParseAudit_Edge_ED07_trimMarkerUnclosed(t *testing.T) {
+ r := parseAudit(`{%- if x -%}content`)
+ assertTemplateNil(t, r, "ED07")
+ requireParseDiag(t, r, "unclosed-tag")
+}
+
+// ED08 — Deeply nested but well-formed blocks (10 levels): no crash, clean.
+func TestParseAudit_Edge_ED08_deepNesting(t *testing.T) {
+ open := ""
+ close := ""
+ for i := 0; i < 10; i++ {
+ open += `{% if x %}content `
+ close = `{% endif %}` + close
+ }
+ r := parseAudit(open + close)
+ assertParseResultNonNil(t, r, "ED08")
+}
+
+// ED09 — {% liquid assign x = 1 %} multi-line tag: no crash, no false diagnostic.
+func TestParseAudit_Edge_ED09_liquidMultilineTag(t *testing.T) {
+ r := parseAudit("{% liquid\nassign x = 1\necho x\n%}")
+ assertParseResultNonNil(t, r, "ED09")
+ // Should not produce spurious diagnostics.
+ for _, d := range r.Diagnostics {
+ if d.Code != "empty-block" {
+ t.Errorf("ED09: unexpected diagnostic code=%q for valid liquid tag", d.Code)
+ }
+ }
+}
+
+// ED10 — Multiple {% assign x = | bad %} tags: each produces its own syntax-error.
+func TestParseAudit_Edge_ED10_multipleTagSyntaxErrors(t *testing.T) {
+ r := parseAudit(`{% assign a = | bad1 %}{% assign b = | bad2 %}`)
+ assertTemplateNonNil(t, r, "ED10")
+ syntaxErrs := allParseDiags(r, "syntax-error")
+ if len(syntaxErrs) < 2 {
+ t.Errorf("ED10: expected >= 2 syntax-error diagnostics, got %d", len(syntaxErrs))
+ }
+}
+
+// ED11 — {{ x | unknown_filter }} + {% if true %}{% endif %}: both diagnostics present.
+func TestParseAudit_Edge_ED11_filterAndEmptyBlock(t *testing.T) {
+ r := parseAudit(`{{ x | unknown_ed11 }}{% if true %}{% endif %}`)
+ if firstParseDiag(r, "undefined-filter") == nil {
+ t.Error("ED11: expected undefined-filter diagnostic")
+ }
+ if firstParseDiag(r, "empty-block") == nil {
+ t.Error("ED11: expected empty-block diagnostic")
+ }
+}
+
+// ED12 — Template with {% break %} / {% continue %} inside for: no crash.
+func TestParseAudit_Edge_ED12_breakContinueInsideFor(t *testing.T) {
+ r := parseAudit(`{% for x in items %}{% if x > 3 %}{% break %}{% endif %}{{ x }}{% endfor %}`)
+ assertParseResultNonNil(t, r, "ED12")
+}
+
+// ED13 — Template with {% increment %} / {% decrement %}: no false diagnostics.
+func TestParseAudit_Edge_ED13_incrementDecrement(t *testing.T) {
+ r := parseAudit(`{% increment counter %}{% decrement counter %}`)
+ assertParseResultNonNil(t, r, "ED13")
+ // Should produce no diagnostics.
+ for _, d := range r.Diagnostics {
+ if d.Code != "empty-block" {
+ t.Errorf("ED13: unexpected diagnostic code=%q for increment/decrement tags", d.Code)
+ }
+ }
+}
+
+// ED14 — Template with {% cycle %} inside for: no crash.
+func TestParseAudit_Edge_ED14_cycleTagInsideFor(t *testing.T) {
+ r := parseAudit(`{% for item in items %}{% cycle "odd","even" %}{{ item }}{% endfor %}`)
+ assertParseResultNonNil(t, r, "ED14")
+}
diff --git a/parse_audit_syntax_test.go b/parse_audit_syntax_test.go
new file mode 100644
index 00000000..396ba806
--- /dev/null
+++ b/parse_audit_syntax_test.go
@@ -0,0 +1,286 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Non-Fatal Errors — syntax-error: single {{ }} (S01–S09)
+// ============================================================================
+
+// S01 — "{{ | bad }}" — invalid expression in object: Template non-nil, syntax-error.
+func TestParseAudit_Syntax_S01_invalidObjectExpr(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ assertTemplateNonNil(t, r, "S01")
+ requireParseDiag(t, r, "syntax-error")
+}
+
+// S02 — "{{ product.price | | round }}" — double pipe: syntax-error.
+func TestParseAudit_Syntax_S02_doublePipe(t *testing.T) {
+ r := parseAudit(`{{ product.price | | round }}`)
+ assertTemplateNonNil(t, r, "S02")
+ requireParseDiag(t, r, "syntax-error")
+}
+
+// S03 — "{{ }}" — empty object expression: syntax-error (if engine rejects empty).
+func TestParseAudit_Syntax_S03_emptyObject(t *testing.T) {
+ r := parseAudit(`{{ }}`)
+ // Engine may or may not treat this as a syntax error.
+ // This test documents the behavior: if diagnostics exist, they should be syntax-error.
+ for _, d := range r.Diagnostics {
+ if d.Code != "syntax-error" && d.Code != "undefined-filter" && d.Code != "empty-block" {
+ t.Errorf("S03: unexpected diagnostic code=%q for empty {{ }}", d.Code)
+ }
+ }
+ // Template must still not panic.
+ assertParseResultNonNil(t, r, "S03")
+}
+
+// S04 — syntax-error Code field equals exactly "syntax-error".
+func TestParseAudit_Syntax_S04_codeField(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ assertDiagField(t, d.Code, "syntax-error", "Code", "syntax-error")
+}
+
+// S05 — syntax-error Severity equals exactly "error".
+func TestParseAudit_Syntax_S05_severityError(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ assertDiagField(t, string(d.Severity), string(liquid.SeverityError), "Severity", "syntax-error")
+}
+
+// S06 — syntax-error Source contains "{{" and "}}" delimiters.
+func TestParseAudit_Syntax_S06_sourceContainsDelimiters(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if len(d.Source) == 0 {
+ t.Fatal("S06: syntax-error Source is empty")
+ }
+ assertDiagContains(t, "Source", d.Source, "{{", "syntax-error")
+}
+
+// S07 — syntax-error Range.Start.Line is correct (line 1 for first-line expr).
+func TestParseAudit_Syntax_S07_rangeStartLine(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Line != 1 {
+ t.Errorf("S07: Range.Start.Line=%d, want 1", d.Range.Start.Line)
+ }
+}
+
+// S08 — syntax-error Range.Start.Column is correct (col 1 for first expression).
+func TestParseAudit_Syntax_S08_rangeStartColumn(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if d.Range.Start.Column < 1 {
+ t.Errorf("S08: Range.Start.Column=%d, want >= 1", d.Range.Start.Column)
+ }
+}
+
+// S09 — syntax-error Message is non-empty and describes the issue.
+func TestParseAudit_Syntax_S09_messagePresentAndNonEmpty(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if len(d.Message) == 0 {
+ t.Fatal("S09: syntax-error Message is empty; should describe the error")
+ }
+}
+
+// ============================================================================
+// Non-Fatal Errors — syntax-error on tag args (ST01–ST04)
+// ============================================================================
+
+// ST01 — {% assign x = | bad %} — broken expression in assign args.
+func TestParseAudit_Syntax_ST01_assignBrokenExpr(t *testing.T) {
+ r := parseAudit(`{% assign x = | bad %}`)
+ assertTemplateNonNil(t, r, "ST01")
+ requireParseDiag(t, r, "syntax-error")
+}
+
+// ST02 — {% if | condition %} — broken expression in if condition.
+func TestParseAudit_Syntax_ST02_ifBrokenExpr(t *testing.T) {
+ r := parseAudit(`{% if | condition %}yes{% endif %}`)
+ // Engine may recover or may treat this as fatal; in either case document behavior.
+ assertParseResultNonNil(t, r, "ST02")
+ // A syntax-error diagnostic should be present.
+ d := firstParseDiag(r, "syntax-error")
+ if d == nil {
+ // Could also be unexpected-tag or unclosed-tag if recovery fails more broadly.
+ t.Logf("ST02: no syntax-error found; codes present: %v", parseDiagCodes(r.Diagnostics))
+ }
+}
+
+// ST03 — {% for %} with missing iteration spec — syntax-error or similar.
+func TestParseAudit_Syntax_ST03_forMissingSpec(t *testing.T) {
+ r := parseAudit(`{% for %}{{ item }}{% endfor %}`)
+ assertParseResultNonNil(t, r, "ST03")
+ // Some error diagnostic must be present.
+ if len(r.Diagnostics) == 0 {
+ t.Fatal("ST03: expected at least one diagnostic for '{% for %}' with no loop spec")
+ }
+}
+
+// ST04 — Tag-level syntax-error: Source contains "{% ... %}" delimiters.
+func TestParseAudit_Syntax_ST04_tagSourceContainsDelimiters(t *testing.T) {
+ r := parseAudit(`{% assign x = | bad %}`)
+ d := requireParseDiag(t, r, "syntax-error")
+ if len(d.Source) == 0 {
+ t.Fatal("ST04: syntax-error Source is empty (tag args error)")
+ }
+ // Source must include the tag delimiters.
+ assertDiagContains(t, "Source", d.Source, "{%", "syntax-error")
+}
+
+// ============================================================================
+// Non-Fatal Errors — multiple syntax-errors (SM01–SM08)
+// ============================================================================
+
+// SM01 — Two bad {{ }} objects in same template: len(Diagnostics)=2, both syntax-error.
+func TestParseAudit_Syntax_SM01_twoSyntaxErrors(t *testing.T) {
+ r := parseAudit(`{{ | bad1 }} text {{ | bad2 }}`)
+ assertTemplateNonNil(t, r, "SM01")
+ syntaxErrs := allParseDiags(r, "syntax-error")
+ if len(syntaxErrs) < 2 {
+ t.Errorf("SM01: expected >= 2 syntax-error diagnostics, got %d (codes: %v)",
+ len(syntaxErrs), parseDiagCodes(r.Diagnostics))
+ }
+}
+
+// SM02 — Three bad {{ }} objects: at least three syntax-error diagnostics.
+func TestParseAudit_Syntax_SM02_threeSyntaxErrors(t *testing.T) {
+ r := parseAudit(`{{ | a }} {{ | b }} {{ | c }}`)
+ assertTemplateNonNil(t, r, "SM02")
+ syntaxErrs := allParseDiags(r, "syntax-error")
+ if len(syntaxErrs) < 3 {
+ t.Errorf("SM02: expected >= 3 syntax-error diagnostics, got %d", len(syntaxErrs))
+ }
+}
+
+// SM03 — Mix of bad {{ }} and bad {% tag %}: all errors collected.
+func TestParseAudit_Syntax_SM03_mixedTagAndObject(t *testing.T) {
+ r := parseAudit(`{{ | bad }}{% assign x = | broken %}`)
+ assertTemplateNonNil(t, r, "SM03")
+ if len(r.Diagnostics) < 2 {
+ t.Errorf("SM03: expected >= 2 diagnostics; got %d (codes: %v)",
+ len(r.Diagnostics), parseDiagCodes(r.Diagnostics))
+ }
+}
+
+// SM04 — Valid text between two bad expressions is rendered correctly
+// (ASTBroken → empty, Text → present).
+func TestParseAudit_Syntax_SM04_validTextBetweenErrors(t *testing.T) {
+ r := parseAudit(`before{{ | bad }}middle{{ | bad2 }}after`)
+ assertTemplateNonNil(t, r, "SM04")
+ // Template should be usable; render should produce "beforemiddleafter".
+ if r.Template != nil {
+ out, err := r.Template.RenderString(liquid.Bindings{})
+ if err != nil {
+ t.Logf("SM04: render returned error: %v", err)
+ }
+ if out != "beforemiddleafter" {
+ t.Errorf("SM04: Output=%q, want %q", out, "beforemiddleafter")
+ }
+ }
+}
+
+// SM05 — ASTBroken renders as empty string: broken node produces no output.
+func TestParseAudit_Syntax_SM05_brokenNodeEmptyOutput(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ assertTemplateNonNil(t, r, "SM05")
+ if r.Template != nil {
+ out, _ := r.Template.RenderString(liquid.Bindings{})
+ if out != "" {
+ t.Errorf("SM05: broken node Output=%q, want empty string", out)
+ }
+ }
+}
+
+// SM06 — Two syntax-errors on different lines: each Diagnostic has a distinct Range.
+func TestParseAudit_Syntax_SM06_distinctRangesPerLine(t *testing.T) {
+ r := parseAudit("{{ | bad1 }}\n{{ | bad2 }}")
+ assertTemplateNonNil(t, r, "SM06")
+ syntaxErrs := allParseDiags(r, "syntax-error")
+ if len(syntaxErrs) < 2 {
+ t.Skip("SM06: less than 2 syntax-error diagnostics; skipping range distinctness check")
+ }
+ if syntaxErrs[0].Range.Start.Line == syntaxErrs[1].Range.Start.Line &&
+ syntaxErrs[0].Range.Start.Column == syntaxErrs[1].Range.Start.Column {
+ t.Errorf("SM06: two diagnostics on different lines share the same Range.Start: %+v",
+ syntaxErrs[0].Range.Start)
+ }
+}
+
+// SM07 — All Diagnostics in multi-error result have distinct (non-duplicate) source fields.
+func TestParseAudit_Syntax_SM07_noIdenticalDuplicates(t *testing.T) {
+ r := parseAudit(`{{ | bad1 }} {{ | bad2 }} {{ | bad3 }}`)
+ assertTemplateNonNil(t, r, "SM07")
+ seen := map[string]bool{}
+ for _, d := range r.Diagnostics {
+ key := d.Code + "|" + d.Source
+ if seen[key] {
+ t.Errorf("SM07: duplicate diagnostic entry code=%q source=%q", d.Code, d.Source)
+ }
+ seen[key] = true
+ }
+}
+
+// SM08 — Multiple bad tokens: len(Diagnostics) matches count of bad tokens.
+func TestParseAudit_Syntax_SM08_countMatchesBadTokens(t *testing.T) {
+ r := parseAudit(`{{ | a }} {{ | b }}`)
+ assertTemplateNonNil(t, r, "SM08")
+ syntaxErrs := allParseDiags(r, "syntax-error")
+ if len(syntaxErrs) != 2 {
+ t.Errorf("SM08: expected exactly 2 syntax-error diagnostics, got %d", len(syntaxErrs))
+ }
+}
+
+// ============================================================================
+// Non-Fatal Errors — rendering a recovered template (SR01–SR03)
+// ============================================================================
+
+// SR01 — Template with syntax-error renders without panic (ASTBroken = empty string).
+func TestParseAudit_Syntax_SR01_renderNoPanic(t *testing.T) {
+ r := parseAudit(`{{ | bad }}`)
+ assertTemplateNonNil(t, r, "SR01")
+ if r.Template != nil {
+ // Must not panic.
+ out, err := r.Template.RenderString(liquid.Bindings{})
+ if err != nil {
+ t.Logf("SR01: render returned error (acceptable): %v", err)
+ }
+ _ = out
+ }
+}
+
+// SR02 — Template with broken expr surrounded by valid content renders correctly.
+func TestParseAudit_Syntax_SR02_validContentAroundBroken(t *testing.T) {
+ r := parseAudit(`Hello {{ | bad }} {{ name }}`)
+ assertTemplateNonNil(t, r, "SR02")
+ if r.Template == nil {
+ t.Skip("SR02: Template is nil, skipping render check")
+ }
+ out, _ := r.Template.RenderString(liquid.Bindings{"name": "Alice"})
+ // Broken node outputs nothing; "Hello " + "" + " " + "Alice" = "Hello Alice"
+ if !containsSubstr(out, "Alice") {
+ t.Errorf("SR02: Output=%q, want it to contain valid variable value 'Alice'", out)
+ }
+}
+
+// SR03 — Template from ParseStringAudit can be used with RenderAudit.
+func TestParseAudit_Syntax_SR03_pipelineWithRenderAudit(t *testing.T) {
+ r := parseAudit(`{{ | bad }} {{ name }}`)
+ assertTemplateNonNil(t, r, "SR03")
+ if r.Template == nil {
+ t.Skip("SR03: Template is nil")
+ }
+ auditResult, _ := r.Template.RenderAudit(
+ liquid.Bindings{"name": "Bob"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ if auditResult == nil {
+ t.Fatal("SR03: RenderAudit returned nil AuditResult")
+ }
+}
diff --git a/parser/ast.go b/parser/ast.go
index 612e874c..b37e63ff 100644
--- a/parser/ast.go
+++ b/parser/ast.go
@@ -40,6 +40,14 @@ type ASTObject struct {
Expr expressions.Expression
}
+// ASTBroken is a node that failed to compile but does not break the block structure.
+// It renders as an empty string. The parser emits a Diagnostic and continues.
+// ASTBroken is produced by the audit parse path only (parseTokensAudit).
+type ASTBroken struct {
+ Token
+ ParseErr error // original compile-time error
+}
+
// ASTSeq is a sequence of nodes.
type ASTSeq struct {
sourcelessNode
diff --git a/parser/error.go b/parser/error.go
index d7a5e7c1..0022d297 100644
--- a/parser/error.go
+++ b/parser/error.go
@@ -8,6 +8,13 @@ type Error interface {
Cause() error
Path() string
LineNumber() int
+ // Message returns the error message without the "Liquid error" prefix or
+ // location information. Useful for re-formatting errors with a different prefix.
+ Message() string
+ // MarkupContext returns the source text of the token/node that produced the
+ // error. For example, for a {{ expr }} node it returns the full "{{ expr }}"
+ // string. Returns an empty string when no source text is available.
+ MarkupContext() string
}
// A Locatable provides source location information for error reporting.
@@ -16,9 +23,29 @@ type Locatable interface {
SourceText() string
}
-// Errorf creates a parser.Error.
-func Errorf(loc Locatable, format string, a ...any) *sourceLocError { //nolint: golint
- return &sourceLocError{loc.SourceLocation(), loc.SourceText(), fmt.Sprintf(format, a...), nil}
+// ParseError is a parse-time syntax error with source location information.
+// The Error() string uses the "Liquid syntax error" prefix, matching Ruby Liquid.
+// Use errors.As to check whether a liquid error originates from parsing.
+//
+// SyntaxError is provided as a type alias so callers can use the more
+// semantically precise name: errors.As(err, new(*parser.SyntaxError)).
+type ParseError struct {
+ *sourceLocError
+}
+
+// SyntaxError is an alias for ParseError. Both names refer to the same type;
+// errors.As patterns using either *ParseError or *SyntaxError are equivalent.
+type SyntaxError = ParseError
+
+// Error overrides sourceLocError.Error to use the "Liquid syntax error" prefix.
+// This matches Ruby Liquid, where parse-time errors are "Liquid syntax error: …".
+func (e *ParseError) Error() string {
+ return e.sourceLocError.errorWithPrefix("Liquid syntax error")
+}
+
+// Errorf creates a parser.Error at the given source location.
+func Errorf(loc Locatable, format string, a ...any) *ParseError { //nolint: golint
+ return &ParseError{&sourceLocError{loc.SourceLocation(), loc.SourceText(), fmt.Sprintf(format, a...), nil}}
}
// WrapError wraps its argument in a parser.Error if this argument is not already a parser.Error and is not locatable.
@@ -57,6 +84,12 @@ func (e *sourceLocError) Cause() error {
return e.cause
}
+// Unwrap returns the underlying cause of this error, enabling errors.As and errors.Is
+// to walk the error chain (e.g. to find a ZeroDivisionError or UndefinedVariableError).
+func (e *sourceLocError) Unwrap() error {
+ return e.cause
+}
+
func (e *sourceLocError) Path() string {
return e.Pathname
}
@@ -65,7 +98,18 @@ func (e *sourceLocError) LineNumber() int {
return e.LineNo
}
-func (e *sourceLocError) Error() string {
+func (e *sourceLocError) Message() string {
+ return e.message
+}
+
+func (e *sourceLocError) MarkupContext() string {
+ return e.context
+}
+
+// errorWithPrefix formats the error message with the given prefix string.
+// This exists so ParseError can override the default "Liquid error" prefix
+// with "Liquid syntax error" without duplicating the formatting logic.
+func (e *sourceLocError) errorWithPrefix(prefix string) string {
line := ""
if e.LineNo > 0 {
line = fmt.Sprintf(" (line %d)", e.LineNo)
@@ -76,5 +120,9 @@ func (e *sourceLocError) Error() string {
locative = " in " + e.Pathname
}
- return fmt.Sprintf("Liquid error%s: %s%s", line, e.message, locative)
+ return fmt.Sprintf("%s%s: %s%s", prefix, line, e.message, locative)
+}
+
+func (e *sourceLocError) Error() string {
+ return e.errorWithPrefix("Liquid error")
}
diff --git a/parser/parse_audit.go b/parser/parse_audit.go
new file mode 100644
index 00000000..bb5243d3
--- /dev/null
+++ b/parser/parse_audit.go
@@ -0,0 +1,171 @@
+package parser
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/osteele/liquid/expressions"
+)
+
+// ParseDiagRelated is supplementary source info for a ParseDiag.
+type ParseDiagRelated struct {
+ Loc SourceLoc
+ Message string
+}
+
+// ParseDiag is an internal parse-time diagnostic.
+// It is converted to the public Diagnostic type at the API boundary.
+type ParseDiag struct {
+ Code string
+ Message string
+ Tok Token
+ Related []ParseDiagRelated
+}
+
+// ParseAudit is the error-recovering variant of Parse.
+// It returns the AST, non-fatal diagnostics (syntax errors), and a fatal error
+// (unclosed-tag or unexpected-tag). All three may be inspected independently:
+// - diags contains only non-fatal issues (syntax-error); it is empty when fatalErr != nil and none occurred before it
+// - fatalErr is non-nil only for the two structural errors that prevent a coherent AST
+func (c *Config) ParseAudit(source string, loc SourceLoc) (ASTNode, []ParseDiag, Error) {
+ tokens := Scan(source, loc, c.Delims)
+ return c.parseTokensAudit(tokens)
+}
+
+// parseTokensAudit is the error-recovering variant of parseTokens.
+// It treats expression parse failures in {{ }} objects as non-fatal syntax-errors.
+// Only the two structural errors (unexpected-tag and unclosed-tag) remain fatal.
+func (c *Config) parseTokensAudit(tokens []Token) (ASTNode, []ParseDiag, Error) { //nolint: gocyclo
+ type frame struct {
+ syntax BlockSyntax
+ node *ASTBlock
+ ap *[]ASTNode
+ }
+
+ var (
+ g = c.Grammar
+ root = &ASTSeq{}
+ ap = &root.Children
+ sd BlockSyntax
+ bn *ASTBlock
+ stack []frame
+ rawTag *ASTRaw
+ inComment = false
+ inRaw = false
+ diags []ParseDiag
+ lastTok Token
+ )
+
+ for _, tok := range tokens {
+ lastTok = tok
+ switch {
+ case inComment:
+ if tok.Type == TagTokenType && (tok.Name == "endcomment" || tok.Name == "enddoc") {
+ inComment = false
+ }
+ case inRaw:
+ if tok.Type == TagTokenType && tok.Name == "endraw" {
+ inRaw = false
+ } else {
+ rawTag.Slices = append(rawTag.Slices, tok.Source)
+ }
+ case tok.Type == ObjTokenType:
+ if tok.Args == "" {
+ break
+ }
+ expr, err := expressions.Parse(tok.Args)
+ if err != nil {
+ // Non-fatal: emit diagnostic and replace with a broken node.
+ diags = append(diags, ParseDiag{
+ Code: "syntax-error",
+ Message: err.Error(),
+ Tok: tok,
+ })
+ *ap = append(*ap, &ASTBroken{Token: tok, ParseErr: err})
+
+ break
+ }
+ *ap = append(*ap, &ASTObject{tok, expr})
+ case tok.Type == TextTokenType:
+ *ap = append(*ap, &ASTText{Token: tok})
+ case tok.Type == TagTokenType:
+ if g == nil {
+ return nil, diags, Errorf(tok, "Grammar field is nil")
+ }
+
+ if cs, ok := g.BlockSyntax(tok.Name); ok {
+ switch {
+ case tok.Name == "comment" || tok.Name == "doc":
+ inComment = true
+ case tok.Name == "raw":
+ inRaw = true
+ rawTag = &ASTRaw{}
+ *ap = append(*ap, rawTag)
+ case cs.RequiresParent() && (sd == nil || !cs.CanHaveParent(sd)):
+ // unexpected-tag: fatal.
+ suffix := ""
+ if sd != nil {
+ suffix = "; immediate parent is " + sd.TagName()
+ }
+ fatalTok := tok
+ fatalErr := Errorf(fatalTok, "%s not inside %s%s", tok.Name, strings.Join(cs.ParentTags(), " or "), suffix)
+ // Emit fatal diagnostic with code unexpected-tag then return.
+ diags = append(diags, ParseDiag{
+ Code: "unexpected-tag",
+ Message: fmt.Sprintf("tag %q is not inside %s%s", tok.Name, strings.Join(cs.ParentTags(), " or "), suffix),
+ Tok: fatalTok,
+ })
+ return nil, diags, fatalErr
+ case cs.IsBlockStart():
+ push := func() {
+ stack = append(stack, frame{syntax: sd, node: bn, ap: ap})
+ sd, bn = cs, &ASTBlock{Token: tok, syntax: cs}
+ *ap = append(*ap, bn)
+ }
+ push()
+ ap = &bn.Body
+ case cs.IsClause():
+ n := &ASTBlock{Token: tok, syntax: cs}
+ bn.Clauses = append(bn.Clauses, n)
+ ap = &n.Body
+ case cs.IsBlockEnd():
+ pop := func() {
+ f := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ sd, bn, ap = f.syntax, f.node, f.ap
+ }
+ pop()
+ default:
+ panic(fmt.Errorf("block type %q", tok.Name))
+ }
+ } else {
+ *ap = append(*ap, &ASTTag{tok})
+ }
+ case tok.Type == TrimLeftTokenType:
+ *ap = append(*ap, &ASTTrim{TrimDirection: Left})
+ case tok.Type == TrimRightTokenType:
+ *ap = append(*ap, &ASTTrim{TrimDirection: Right})
+ }
+ }
+
+ if bn != nil {
+ // unclosed-tag: fatal.
+ // The Related entry points to the end-of-template position.
+ endLoc := lastTok.EndLoc
+ if endLoc.LineNo == 0 {
+ endLoc = lastTok.SourceLoc
+ }
+ diags = append(diags, ParseDiag{
+ Code: "unclosed-tag",
+ Message: fmt.Sprintf("tag %q opened here was never closed", bn.Name),
+ Tok: bn.Token,
+ Related: []ParseDiagRelated{{
+ Loc: endLoc,
+ Message: fmt.Sprintf("expected {%% end%s %%} before end of template", bn.Name),
+ }},
+ })
+ return nil, diags, Errorf(bn, "unterminated %q block", bn.Name)
+ }
+
+ return root, diags, nil
+}
diff --git a/parser/parser.go b/parser/parser.go
index c9f86a23..2ef09c7a 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -41,7 +41,7 @@ func (c *Config) parseTokens(tokens []Token) (ASTNode, Error) { //nolint: gocycl
// needn't match each other e.g. {%comment%}{%if%}{%endcomment%}
// TODO is this true?
case inComment:
- if tok.Type == TagTokenType && tok.Name == "endcomment" {
+ if tok.Type == TagTokenType && (tok.Name == "endcomment" || tok.Name == "enddoc") {
inComment = false
}
case inRaw:
@@ -51,6 +51,10 @@ func (c *Config) parseTokens(tokens []Token) (ASTNode, Error) { //nolint: gocycl
rawTag.Slices = append(rawTag.Slices, tok.Source)
}
case tok.Type == ObjTokenType:
+ if tok.Args == "" {
+ // Empty expression (e.g. from {{-}} where - is a trim marker only); outputs nothing.
+ break
+ }
expr, err := expressions.Parse(tok.Args)
if err != nil {
return nil, WrapError(err, tok)
@@ -66,7 +70,7 @@ func (c *Config) parseTokens(tokens []Token) (ASTNode, Error) { //nolint: gocycl
if cs, ok := g.BlockSyntax(tok.Name); ok {
switch {
- case tok.Name == "comment":
+ case tok.Name == "comment" || tok.Name == "doc":
inComment = true
case tok.Name == "raw":
inRaw = true
diff --git a/parser/scanner.go b/parser/scanner.go
index 7f5f646e..e5665ad9 100644
--- a/parser/scanner.go
+++ b/parser/scanner.go
@@ -35,30 +35,75 @@ func Scan(data string, loc SourceLoc, delims []string) (tokens []Token) {
// TODO error on unterminated {{ and {%
// TODO probably an error when a tag contains a {{ or {%, at least outside of a string
+
+ // lastNL is the byte offset of the most recent '\n' in data, or -1 before the first one.
+ // Column of byte at position pos is: pos - lastNL (1-based).
+ lastNL := -1
+
+ // If the initial loc already has a ColNo set, back-compute the effective lastNL so that
+ // position 0 maps to that column. Otherwise column 1 starts at position 0.
+ if loc.ColNo > 1 {
+ lastNL = -(loc.ColNo - 1)
+ }
+
+ colOf := func(pos int) int { return pos - lastNL }
+
+ // advanceNL updates lastNL and loc.LineNo for the newlines in data[from:to].
+ advanceNL := func(from, to int) {
+ chunk := data[from:to]
+ n := strings.Count(chunk, "\n")
+ if n > 0 {
+ loc.LineNo += n
+ lastNL = from + strings.LastIndex(chunk, "\n")
+ }
+ }
+
p, pe := 0, len(data)
for _, m := range tokenMatcher.FindAllStringSubmatchIndex(data, -1) {
ts, te := m[0], m[1]
if p < ts {
- tokens = append(tokens, Token{Type: TextTokenType, SourceLoc: loc, Source: data[p:ts]})
- loc.LineNo += strings.Count(data[p:ts], "\n")
+ textLoc := loc
+ textLoc.ColNo = colOf(p)
+ text := data[p:ts]
+ tokens = append(tokens, Token{
+ Type: TextTokenType,
+ SourceLoc: textLoc,
+ EndLoc: tokenEndLoc(textLoc, text),
+ Source: text,
+ })
+ advanceNL(p, ts)
}
source := data[ts:te]
+ tokLoc := loc
+ tokLoc.ColNo = colOf(ts)
+ tokEndLoc := tokenEndLoc(tokLoc, source)
+
switch {
case data[ts:ts+len(delims[0])] == delims[0]:
- if source[2] == '-' {
+ leftTrim := source[2] == '-'
+ rightTrim := source[len(source)-3] == '-'
+ if leftTrim {
tokens = append(tokens, Token{
Type: TrimLeftTokenType,
})
}
+ // When the only captured content is the trim marker itself (e.g. {{-}} or {{- -}}),
+ // treat the expression as empty so it renders nothing rather than producing a parse error.
+ args := data[m[2]:m[3]]
+ if args == "-" && (leftTrim || rightTrim) {
+ args = ""
+ }
+
tokens = append(tokens, Token{
Type: ObjTokenType,
- SourceLoc: loc,
+ SourceLoc: tokLoc,
+ EndLoc: tokEndLoc,
Source: source,
- Args: data[m[2]:m[3]],
+ Args: args,
})
- if source[len(source)-3] == '-' {
+ if rightTrim {
tokens = append(tokens, Token{
Type: TrimRightTokenType,
})
@@ -70,17 +115,24 @@ func Scan(data string, loc SourceLoc, delims []string) (tokens []Token) {
})
}
- tok := Token{
- Type: TagTokenType,
- SourceLoc: loc,
- Source: source,
- Name: data[m[4]:m[5]],
- }
- if m[6] > 0 {
- tok.Args = data[m[6]:m[7]]
+ // m[4] < 0 means the (\w+) tag-name group didn't match.
+ // This happens for inline comments: {%# ... %} where '#' is not \w.
+ // In that case we emit only trim markers (if any) but no tag token.
+ if m[4] >= 0 {
+ tok := Token{
+ Type: TagTokenType,
+ SourceLoc: tokLoc,
+ EndLoc: tokEndLoc,
+ Source: source,
+ Name: data[m[4]:m[5]],
+ }
+ if m[6] > 0 {
+ tok.Args = data[m[6]:m[7]]
+ }
+
+ tokens = append(tokens, tok)
}
- tokens = append(tokens, tok)
if source[len(source)-3] == '-' {
tokens = append(tokens, Token{
Type: TrimRightTokenType,
@@ -88,17 +140,44 @@ func Scan(data string, loc SourceLoc, delims []string) (tokens []Token) {
}
}
- loc.LineNo += strings.Count(source, "\n")
+ advanceNL(ts, te)
p = te
}
if p < pe {
- tokens = append(tokens, Token{Type: TextTokenType, SourceLoc: loc, Source: data[p:]})
+ textLoc := loc
+ textLoc.ColNo = colOf(p)
+ text := data[p:]
+ tokens = append(tokens, Token{
+ Type: TextTokenType,
+ SourceLoc: textLoc,
+ EndLoc: tokenEndLoc(textLoc, text),
+ Source: text,
+ })
}
return tokens
}
+// tokenEndLoc computes the exclusive end location of a token given its start
+// location and source text.
+func tokenEndLoc(start SourceLoc, source string) SourceLoc {
+ nls := strings.Count(source, "\n")
+ if nls == 0 {
+ return SourceLoc{
+ Pathname: start.Pathname,
+ LineNo: start.LineNo,
+ ColNo: start.ColNo + len(source),
+ }
+ }
+ lastNL := strings.LastIndex(source, "\n")
+ return SourceLoc{
+ Pathname: start.Pathname,
+ LineNo: start.LineNo + nls,
+ ColNo: len(source) - lastNL, // 1-based col of character after last \n
+ }
+}
+
func formTokenMatcher(delims []string) *regexp.Regexp {
// On ending a tag we need to exclude anything that appears to be ending a tag that's nested
// inside the tag. We form the exclusion expression here.
@@ -113,10 +192,25 @@ func formTokenMatcher(delims []string) *regexp.Regexp {
}
}
+ // Build the same exclusion pattern for the OUTPUT right delimiter (delims[1], e.g. "}}").
+ // This prevents the lazy content group from matching across an intermediate closing delimiter,
+ // which would otherwise cause adjacent {{-}} tokens to merge into a single (broken) match.
+ outputExclusion := make([]string, 0, len(delims[1]))
+ for idx, val := range delims[1] {
+ oe := "[^" + string(val) + "]"
+ if idx > 0 {
+ oe = delims[1][0:idx] + oe
+ }
+ outputExclusion = append(outputExclusion, oe)
+ }
+
tokenMatcher := regexp.MustCompile(
- fmt.Sprintf(`%s-?\s*(.+?)\s*-?%s|%s-?\s*(\w+)(?:\s+((?:%v)+?))?\s*-?%s`,
- // QuoteMeta will escape any of these that are regex commands
- regexp.QuoteMeta(delims[0]), regexp.QuoteMeta(delims[1]),
+ fmt.Sprintf(`%s-?\s*((?:%v)+?)\s*-?%s|%s-?\s*#(?:(?:%v)*)-?%s|%s-?\s*(\w+)(?:\s+((?:%v)+?))?\s*-?%s`,
+ // Output token: content must not contain the closing delimiter (outputExclusion).
+ regexp.QuoteMeta(delims[0]), strings.Join(outputExclusion, "|"), regexp.QuoteMeta(delims[1]),
+ // Inline comment alternative: {%#...%} or {%- # ...%} — optional whitespace between trim marker and #.
+ // No capturing groups so existing group indices are unchanged.
+ regexp.QuoteMeta(delims[2]), strings.Join(exclusion, "|"), regexp.QuoteMeta(delims[3]),
regexp.QuoteMeta(delims[2]), strings.Join(exclusion, "|"), regexp.QuoteMeta(delims[3]),
),
)
diff --git a/parser/scanner_test.go b/parser/scanner_test.go
index e2a5b37d..1b93cb9f 100644
--- a/parser/scanner_test.go
+++ b/parser/scanner_test.go
@@ -77,9 +77,11 @@ func TestScan_ws(t *testing.T) {
}{
{`{{ expr }}`, []Token{
{
- Type: ObjTokenType,
- Args: "expr",
- Source: "{{ expr }}",
+ Type: ObjTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 11},
+ Args: "expr",
+ Source: "{{ expr }}",
},
}},
{`{{- expr }}`, []Token{
@@ -87,16 +89,20 @@ func TestScan_ws(t *testing.T) {
Type: TrimLeftTokenType,
},
{
- Type: ObjTokenType,
- Args: "expr",
- Source: "{{- expr }}",
+ Type: ObjTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 12},
+ Args: "expr",
+ Source: "{{- expr }}",
},
}},
{`{{ expr -}}`, []Token{
{
- Type: ObjTokenType,
- Args: "expr",
- Source: "{{ expr -}}",
+ Type: ObjTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 12},
+ Args: "expr",
+ Source: "{{ expr -}}",
},
{
Type: TrimRightTokenType,
@@ -107,9 +113,11 @@ func TestScan_ws(t *testing.T) {
Type: TrimLeftTokenType,
},
{
- Type: ObjTokenType,
- Args: "expr",
- Source: "{{- expr -}}",
+ Type: ObjTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 13},
+ Args: "expr",
+ Source: "{{- expr -}}",
},
{
Type: TrimRightTokenType,
@@ -117,10 +125,12 @@ func TestScan_ws(t *testing.T) {
}},
{`{% tag arg %}`, []Token{
{
- Type: TagTokenType,
- Name: "tag",
- Args: "arg",
- Source: "{% tag arg %}",
+ Type: TagTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 14},
+ Name: "tag",
+ Args: "arg",
+ Source: "{% tag arg %}",
},
}},
{`{%- tag arg %}`, []Token{
@@ -128,18 +138,22 @@ func TestScan_ws(t *testing.T) {
Type: TrimLeftTokenType,
},
{
- Type: TagTokenType,
- Name: "tag",
- Args: "arg",
- Source: "{%- tag arg %}",
+ Type: TagTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 15},
+ Name: "tag",
+ Args: "arg",
+ Source: "{%- tag arg %}",
},
}},
{`{% tag arg -%}`, []Token{
{
- Type: TagTokenType,
- Name: "tag",
- Args: "arg",
- Source: "{% tag arg -%}",
+ Type: TagTokenType,
+ SourceLoc: SourceLoc{ColNo: 1},
+ EndLoc: SourceLoc{ColNo: 15},
+ Name: "tag",
+ Args: "arg",
+ Source: "{% tag arg -%}",
},
{
Type: TrimRightTokenType,
diff --git a/parser/token.go b/parser/token.go
index c03acdd7..5a9f0b82 100644
--- a/parser/token.go
+++ b/parser/token.go
@@ -6,9 +6,10 @@ import "fmt"
type Token struct {
Type TokenType
SourceLoc SourceLoc
- Name string // Name is the tag name of a tag Chunk. E.g. the tag name of "{% if 1 %}" is "if".
- Args string // Parameters is the tag arguments of a tag Chunk. E.g. the tag arguments of "{% if 1 %}" is "1".
- Source string // Source is the entirety of the token, including the "{{", "{%", etc. markers.
+ EndLoc SourceLoc // End location (exclusive) of this token; zero if not tracked.
+ Name string // Name is the tag name of a tag Chunk. E.g. the tag name of "{% if 1 %}" is "if".
+ Args string // Parameters is the tag arguments of a tag Chunk. E.g. the tag arguments of "{% if 1 %}" is "1".
+ Source string // Source is the entirety of the token, including the "{{" "{%", etc. markers.
}
// TokenType is the type of a Chunk
@@ -35,6 +36,7 @@ const (
type SourceLoc struct {
Pathname string
LineNo int
+ ColNo int // 1-based column number; 0 means not tracked
}
// SourceLocation returns the token's source location, for use in error reporting.
diff --git a/prea_validate_test.go b/prea_validate_test.go
new file mode 100644
index 00000000..6dfbfd30
--- /dev/null
+++ b/prea_validate_test.go
@@ -0,0 +1,133 @@
+package liquid_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/osteele/liquid"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPREA_Integration validates all 6 items from PRE-A end-to-end.
+func TestPREA_Integration(t *testing.T) {
+ eng := liquid.NewEngine()
+
+ check := func(t *testing.T, tpl, expected string, bindings map[string]any) {
+ t.Helper()
+ out, err := eng.ParseAndRenderString(tpl, bindings)
+ require.NoError(t, err)
+ require.Equal(t, expected, out)
+ }
+
+ // 1. empty literal
+ t.Run("empty_literal_empty_string", func(t *testing.T) {
+ check(t, `{% if x == empty %}yes{% endif %}`, "yes", map[string]any{"x": ""})
+ })
+ t.Run("empty_literal_empty_array", func(t *testing.T) {
+ check(t, `{% if x == empty %}yes{% endif %}`, "yes", map[string]any{"x": []any{}})
+ })
+ t.Run("empty_literal_empty_map", func(t *testing.T) {
+ check(t, `{% if x == empty %}yes{% endif %}`, "yes", map[string]any{"x": map[string]any{}})
+ })
+ t.Run("empty_literal_nonempty", func(t *testing.T) {
+ check(t, `{% if x == empty %}yes{% else %}no{% endif %}`, "no", map[string]any{"x": "hi"})
+ })
+ t.Run("empty_output", func(t *testing.T) {
+ // empty in output context should render as empty string
+ check(t, `{{ empty }}`, "", nil)
+ })
+
+ // 2. blank literal
+ t.Run("blank_literal_nil", func(t *testing.T) {
+ check(t, `{% if x == blank %}yes{% endif %}`, "yes", map[string]any{"x": nil})
+ })
+ t.Run("blank_literal_false", func(t *testing.T) {
+ check(t, `{% if x == blank %}yes{% endif %}`, "yes", map[string]any{"x": false})
+ })
+ t.Run("blank_literal_whitespace", func(t *testing.T) {
+ check(t, `{% if x == blank %}yes{% endif %}`, "yes", map[string]any{"x": " "})
+ })
+ t.Run("blank_literal_empty_string", func(t *testing.T) {
+ check(t, `{% if x == blank %}yes{% endif %}`, "yes", map[string]any{"x": ""})
+ })
+ t.Run("blank_literal_nonempty", func(t *testing.T) {
+ check(t, `{% if x == blank %}yes{% else %}no{% endif %}`, "no", map[string]any{"x": "hello"})
+ })
+
+ // 3. string escape sequences
+ t.Run("escape_newline", func(t *testing.T) {
+ check(t, `{{ "hello\nworld" }}`, "hello\nworld", nil)
+ })
+ t.Run("escape_tab", func(t *testing.T) {
+ check(t, `{{ "col1\tcol2" }}`, "col1\tcol2", nil)
+ })
+ t.Run("escape_single_quote", func(t *testing.T) {
+ check(t, `{{ 'it\'s fine' }}`, "it's fine", nil)
+ })
+ t.Run("escape_double_quote_in_double", func(t *testing.T) {
+ check(t, `{{ "say \"hi\"" }}`, `say "hi"`, nil)
+ })
+ t.Run("escape_backslash", func(t *testing.T) {
+ check(t, `{{ "a\\b" }}`, `a\b`, nil)
+ })
+ t.Run("escape_carriage_return", func(t *testing.T) {
+ check(t, `{{ "a\rb" }}`, "a\rb", nil)
+ })
+
+ // 4. <> operator (alias for !=)
+ t.Run("diamond_ne_true", func(t *testing.T) {
+ check(t, `{% if 1 <> 2 %}yes{% endif %}`, "yes", nil)
+ })
+ t.Run("diamond_ne_false", func(t *testing.T) {
+ check(t, `{% if 1 <> 1 %}yes{% else %}no{% endif %}`, "no", nil)
+ })
+ t.Run("diamond_string", func(t *testing.T) {
+ check(t, `{% if "a" <> "b" %}yes{% endif %}`, "yes", nil)
+ })
+
+ // 5. not operator
+ t.Run("not_false_is_true", func(t *testing.T) {
+ check(t, `{% if not false %}yes{% endif %}`, "yes", nil)
+ })
+ t.Run("not_true_is_false", func(t *testing.T) {
+ check(t, `{% if not true %}yes{% else %}no{% endif %}`, "no", nil)
+ })
+ t.Run("not_nil_is_true", func(t *testing.T) {
+ check(t, `{% if not nil %}yes{% endif %}`, "yes", nil)
+ })
+ t.Run("not_nonempty_string_is_false", func(t *testing.T) {
+ check(t, `{% if not x %}yes{% else %}no{% endif %}`, "no", map[string]any{"x": "hello"})
+ })
+ t.Run("not_with_and", func(t *testing.T) {
+ // not a and b → (not a) and b
+ check(t, `{% if not false and true %}yes{% endif %}`, "yes", nil)
+ })
+
+ // 6. case/when with or
+ t.Run("when_or_first_value", func(t *testing.T) {
+ check(t, `{% case x %}{% when 1 or 2 %}match{% else %}no{% endcase %}`, "match", map[string]any{"x": 1})
+ })
+ t.Run("when_or_second_value", func(t *testing.T) {
+ check(t, `{% case x %}{% when 1 or 2 %}match{% else %}no{% endcase %}`, "match", map[string]any{"x": 2})
+ })
+ t.Run("when_or_no_match", func(t *testing.T) {
+ check(t, `{% case x %}{% when 1 or 2 %}match{% else %}no{% endcase %}`, "no", map[string]any{"x": 3})
+ })
+
+ // 7. keyword args in filter (NamedArg plumbing)
+ t.Run("keyword_arg_named_arg_type", func(t *testing.T) {
+ // Verify NamedArg is passed through to filter — use a custom engine to test
+ eng2 := liquid.NewEngine()
+ var gotArg any
+ eng2.RegisterFilter("spy", func(v any, args ...any) any {
+ for _, a := range args {
+ gotArg = a
+ }
+ return fmt.Sprintf("%v", v)
+ })
+ _, err := eng2.ParseAndRenderString(`{{ x | spy: "pos", flag: true }}`, map[string]any{"x": "test"})
+ require.NoError(t, err)
+ // gotArg should be a NamedArg
+ require.NotNil(t, gotArg, "expected NamedArg to be passed to filter")
+ })
+}
diff --git a/probe_s4_test.go b/probe_s4_test.go
new file mode 100644
index 00000000..68211acd
--- /dev/null
+++ b/probe_s4_test.go
@@ -0,0 +1,22 @@
+package liquid_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+func TestProbeSection4(t *testing.T) {
+ eng := liquid.NewEngine()
+
+ probe := func(tpl string) {
+ out, err := eng.ParseAndRenderString(tpl, nil)
+ fmt.Printf("tpl=%-60s got=%q err=%v\n", tpl, out, err)
+ }
+
+ probe("{% if (1..5) contains 3 %}yes{% else %}no{% endif %}")
+ probe("{% if (1..5) contains 6 %}yes{% else %}no{% endif %}")
+ probe("{% if null <= 0 %} true {% else %} false {% endif %}")
+ probe("{% if 0 <= null %} true {% else %} false {% endif %}")
+}
diff --git a/render/analysis.go b/render/analysis.go
new file mode 100644
index 00000000..cabafe6f
--- /dev/null
+++ b/render/analysis.go
@@ -0,0 +1,221 @@
+package render
+
+import (
+ "strings"
+
+ "github.com/osteele/liquid/expressions"
+ "github.com/osteele/liquid/parser"
+)
+
+// NodeAnalysis holds static analysis metadata for a compiled node.
+// Populated at compile time by tag/block analyzers.
+type NodeAnalysis struct {
+ // Arguments are expressions whose variable references are "used" by this node.
+ // Analogous to LiquidJS tag.arguments().
+ Arguments []expressions.Expression
+
+ // LocalScope lists variable names DEFINED by this node in the current scope.
+ // Analogous to LiquidJS tag.localScope(). E.g. assign, capture.
+ LocalScope []string
+
+ // BlockScope lists variable names added to the scope for this node's BODY only.
+ // Analogous to LiquidJS tag.blockScope(). E.g. the loop variable in for.
+ BlockScope []string
+
+ // ChildNodes holds compiled sub-trees that should be included in static analysis.
+ // Used by composite tags like {% liquid %} that compile inner templates at parse time.
+ ChildNodes []Node
+}
+
+// TagAnalyzer provides static analysis metadata for a simple tag.
+type TagAnalyzer func(args string) NodeAnalysis
+
+// BlockAnalyzer provides static analysis metadata for a block tag.
+// It receives the already-compiled BlockNode (with Body and Clauses populated).
+type BlockAnalyzer func(node BlockNode) NodeAnalysis
+
+// VariableRef is a variable path paired with the source location where it is referenced.
+type VariableRef struct {
+ Path []string
+ Loc parser.SourceLoc
+}
+
+// AnalysisResult is the result of static analysis of a compiled template.
+type AnalysisResult struct {
+ // Globals contains variable paths that come from the outer scope (not defined
+ // within the template itself via assign, capture, for, etc.).
+ Globals [][]string
+ // All contains all variable paths referenced in the template, including locals.
+ All [][]string
+
+ // GlobalRefs contains global variable references with source locations.
+ GlobalRefs []VariableRef
+ // AllRefs contains all variable references with source locations.
+ AllRefs []VariableRef
+
+ // Locals contains variable names defined within the template (assign, capture, for, etc.).
+ Locals []string
+
+ // Tags contains the unique tag names used in the template (e.g. "if", "for", "assign").
+ Tags []string
+}
+
+// Analyze performs static analysis on a compiled template tree and returns
+// the set of variable paths referenced by the template.
+func Analyze(root Node) AnalysisResult {
+ locals := map[string]bool{}
+ var localList []string
+ collectLocals(root, locals, &localList)
+
+ collector := &analysisCollector{seen: map[string]bool{}}
+ walkForVariables(root, collector)
+
+ allRefs := collector.refs
+ all := make([][]string, len(allRefs))
+ for i, r := range allRefs {
+ all[i] = r.Path
+ }
+
+ var globals [][]string
+ var globalRefs []VariableRef
+ for _, ref := range allRefs {
+ if len(ref.Path) > 0 && !locals[ref.Path[0]] {
+ globals = append(globals, ref.Path)
+ globalRefs = append(globalRefs, ref)
+ }
+ }
+
+ tagSeen := map[string]bool{}
+ var tags []string
+ walkForTags(root, tagSeen, &tags)
+
+ return AnalysisResult{
+ All: all,
+ Globals: globals,
+ AllRefs: allRefs,
+ GlobalRefs: globalRefs,
+ Locals: localList,
+ Tags: tags,
+ }
+}
+
+// analysisCollector deduplicates variable paths across the full AST walk,
+// preserving the source location of the first occurrence of each path.
+type analysisCollector struct {
+ refs []VariableRef
+ seen map[string]bool
+}
+
+func (c *analysisCollector) addRef(path []string, loc parser.SourceLoc) {
+ if len(path) == 0 {
+ return
+ }
+ key := strings.Join(path, "\x00")
+ if !c.seen[key] {
+ c.seen[key] = true
+ cp := make([]string, len(path))
+ copy(cp, path)
+ c.refs = append(c.refs, VariableRef{Path: cp, Loc: loc})
+ }
+}
+
+func (c *analysisCollector) addFromExpr(expr expressions.Expression, loc parser.SourceLoc) {
+ for _, path := range expr.Variables() {
+ c.addRef(path, loc)
+ }
+}
+
+// walkForVariables traverses the AST collecting all variable references with their locations.
+func walkForVariables(node Node, collector *analysisCollector) {
+ switch n := node.(type) {
+ case *SeqNode:
+ for _, child := range n.Children {
+ walkForVariables(child, collector)
+ }
+ case *ObjectNode:
+ collector.addFromExpr(n.GetExpr(), n.SourceLoc)
+ case *TagNode:
+ for _, expr := range n.Analysis.Arguments {
+ collector.addFromExpr(expr, n.SourceLoc)
+ }
+ for _, child := range n.Analysis.ChildNodes {
+ walkForVariables(child, collector)
+ }
+ case *BlockNode:
+ for _, expr := range n.Analysis.Arguments {
+ collector.addFromExpr(expr, n.SourceLoc)
+ }
+ for _, child := range n.Body {
+ walkForVariables(child, collector)
+ }
+ for _, clause := range n.Clauses {
+ walkForVariables(clause, collector)
+ }
+ }
+}
+
+// collectLocals traverses the AST collecting all locally-defined variable names.
+// These are names introduced by assign, capture, for (BlockScope), etc.
+func collectLocals(node Node, locals map[string]bool, list *[]string) {
+ addLocal := func(name string) {
+ if !locals[name] {
+ locals[name] = true
+ *list = append(*list, name)
+ }
+ }
+ switch n := node.(type) {
+ case *SeqNode:
+ for _, child := range n.Children {
+ collectLocals(child, locals, list)
+ }
+ case *TagNode:
+ for _, name := range n.Analysis.LocalScope {
+ addLocal(name)
+ }
+ for _, child := range n.Analysis.ChildNodes {
+ collectLocals(child, locals, list)
+ }
+ case *BlockNode:
+ for _, name := range n.Analysis.LocalScope {
+ addLocal(name)
+ }
+ for _, name := range n.Analysis.BlockScope {
+ addLocal(name)
+ }
+ for _, child := range n.Body {
+ collectLocals(child, locals, list)
+ }
+ for _, clause := range n.Clauses {
+ collectLocals(clause, locals, list)
+ }
+ }
+}
+
+// walkForTags traverses the AST collecting unique tag names (e.g. "if", "for", "assign").
+func walkForTags(node Node, seen map[string]bool, tags *[]string) {
+ switch n := node.(type) {
+ case *SeqNode:
+ for _, child := range n.Children {
+ walkForTags(child, seen, tags)
+ }
+ case *TagNode:
+ if !seen[n.Name] {
+ seen[n.Name] = true
+ *tags = append(*tags, n.Name)
+ }
+ for _, child := range n.Analysis.ChildNodes {
+ walkForTags(child, seen, tags)
+ }
+ case *BlockNode:
+ if !seen[n.Name] {
+ seen[n.Name] = true
+ *tags = append(*tags, n.Name)
+ }
+ for _, child := range n.Body {
+ walkForTags(child, seen, tags)
+ }
+ for _, clause := range n.Clauses {
+ walkForTags(clause, seen, tags)
+ }
+ }
+}
diff --git a/render/audit_types.go b/render/audit_types.go
new file mode 100644
index 00000000..a0ebe141
--- /dev/null
+++ b/render/audit_types.go
@@ -0,0 +1,303 @@
+package render
+
+import "github.com/osteele/liquid/parser"
+
+// FilterStep records a single filter application during expression evaluation.
+// Used internally by the render layer and re-exported by the liquid package.
+type FilterStep struct {
+ Filter string `json:"filter"`
+ Args []any `json:"args"`
+ Input any `json:"input"`
+ Output any `json:"output"`
+}
+
+// AuditComparison records a single primitive binary comparison inside a condition test.
+type AuditComparison struct {
+ Expression string // raw source text of this comparison; empty when not tracked
+ Operator string // "==", "!=", ">", "<", ">=", "<=", "contains"
+ Left any // evaluated left operand
+ Right any // evaluated right operand
+ Result bool // outcome of this comparison
+}
+
+// AuditConditionNode is a node in a condition branch's items tree.
+// Exactly one of Comparison or Group is non-nil.
+type AuditConditionNode struct {
+ Comparison *AuditComparison
+ Group *AuditGroup
+}
+
+// AuditGroup represents a logical and/or operator with its operands.
+type AuditGroup struct {
+ Operator string // "and" | "or"
+ Result bool
+ Items []AuditConditionNode // sub-nodes (comparisons and nested groups)
+}
+
+// AuditBranch records a single branch of an {% if %}, {% unless %}, or {% case %} block.
+type AuditBranch struct {
+ Kind string // "if", "elsif", "else", "when", "unless"
+ LocStart parser.SourceLoc // start of the branch header tag
+ LocEnd parser.SourceLoc // end of the branch header tag
+ Source string // raw source of the branch header tag
+ Executed bool // whether this branch's body was rendered
+ Items []AuditConditionNode // condition items tree (comparisons and groups); empty for "else"
+}
+
+// AuditIterInfo records metadata about a for/tablerow iteration block.
+type AuditIterInfo struct {
+ Variable string
+ Collection string
+ Length int
+ Limit *int
+ Offset *int
+ Reversed bool
+ Truncated bool
+ TracedCount int
+}
+
+// AuditHooks contains optional callback functions invoked during rendering for
+// audit and trace collection. A nil pointer means no audit is active
+// (zero-cost path on the normal render path).
+//
+// The struct also holds mutable state used by the render layer during a single
+// render call; it must NOT be shared between concurrent renders.
+type AuditHooks struct {
+ // Callback functions — set once before the render begins, read-only during render:
+
+ // OnObject is called when an {{ expr }} node is evaluated.
+ // err is non-nil when evaluation failed; value will be nil in that case.
+ // OnError is also called separately for error cases.
+ OnObject func(start, end parser.SourceLoc, source, name string, parts []string, value any, pipeline []FilterStep, depth int, err error)
+
+ // OnCondition is called for {% if %}, {% unless %}, {% case %} blocks.
+ OnCondition func(start, end parser.SourceLoc, source string, branches []AuditBranch, depth int)
+
+ // OnIteration is called for {% for %}, {% tablerow %} blocks.
+ OnIteration func(start, end parser.SourceLoc, source string, it AuditIterInfo, depth int)
+
+ // OnAssignment is called for {% assign %}.
+ OnAssignment func(start, end parser.SourceLoc, source, varname string, path []string, value any, pipeline []FilterStep, depth int)
+
+ // OnCapture is called for {% capture %}.
+ OnCapture func(start, end parser.SourceLoc, source, varname, value string, depth int)
+
+ // OnError is called when a render-time error is encountered.
+ OnError func(start, end parser.SourceLoc, source string, err error)
+
+ // OnWarning is called for render-time issues that are not fatal errors:
+ // type-mismatch, not-iterable, and nil-dereference.
+ // code is a machine-readable key; message is human-readable.
+ OnWarning func(start, end parser.SourceLoc, source string, code, message string)
+
+ // MaxIterItems limits how many loop iterations have their inner expressions
+ // traced. 0 means unlimited. When the limit is reached, inner expressions
+ // for subsequent iterations are not traced (hooks are not called for them).
+ MaxIterItems int
+
+ // Mutable render state — managed by the render layer, not the caller:
+
+ // filterTarget is set by ObjectNode.render() before Evaluate() and cleared
+ // after. The FilterHook in expressions.Config writes steps here.
+ filterTarget *[]FilterStep
+
+ // currentLocStart/End/Source track the source range of the node currently
+ // being evaluated. Set by ObjectNode.render() (for nil-dereference) and
+ // control_flow_tags (for type-mismatch) before Evaluate(), cleared after.
+ currentLocStart parser.SourceLoc
+ currentLocEnd parser.SourceLoc
+ currentLocSource string
+
+ // depth is incremented when entering a block body (via RenderBlock/RenderChildren)
+ // and decremented on exit. Used to populate Expression.Depth in the public API.
+ depth int
+
+ // iterCount is a per-loop-depth iteration counter used for MaxIterItems.
+ // It is set/reset by the loop tag renderer.
+ iterCount int
+
+ // suppressInner is true when MaxIterItems has been reached for the current
+ // loop; the render layer skips calling hooks while it is set.
+ suppressInner bool
+
+ // conditionActive is the currently active items slice for collecting
+ // condition nodes (comparisons and groups) during branch test evaluation.
+ conditionActive *[]AuditConditionNode
+
+ // conditionGroupStack holds parent slices suspended when BeginGroup is
+ // called for a nested and/or sub-expression.
+ conditionGroupStack []*[]AuditConditionNode
+
+ // currentBranchSource holds the raw source text of the branch currently
+ // being evaluated (e.g. "customer.age >= 18"). Read by AppendComparison
+ // to populate AuditComparison.Expression.
+ currentBranchSource string
+}
+
+// EmitWarning calls OnWarning if set. It is a no-op when audit is not active.
+func (a *AuditHooks) EmitWarning(start, end parser.SourceLoc, source string, code, message string) {
+ if a != nil && a.OnWarning != nil {
+ a.OnWarning(start, end, source, code, message)
+ }
+}
+
+// SetCurrentLoc stores the source range of the node currently being evaluated.
+// Called before Evaluate() (by ObjectNode.render and control_flow_tags) and
+// cleared after, so that TypeMismatchHook/NilDereferenceHook closures can read it.
+func (a *AuditHooks) SetCurrentLoc(start, end parser.SourceLoc, source string) {
+ if a != nil {
+ a.currentLocStart = start
+ a.currentLocEnd = end
+ a.currentLocSource = source
+ }
+}
+
+// CurrentLoc returns the source range stored by the most recent SetCurrentLoc call.
+func (a *AuditHooks) CurrentLoc() (parser.SourceLoc, parser.SourceLoc, string) {
+ if a == nil {
+ return parser.SourceLoc{}, parser.SourceLoc{}, ""
+ }
+ return a.currentLocStart, a.currentLocEnd, a.currentLocSource
+}
+
+// SetConditionTarget sets up (target != nil) or tears down (target == nil)
+// condition node collection for a single branch test evaluation.
+// source is the raw source text of the branch test expression (e.g. "x >= 10");
+// it is stored so that AppendComparison can populate AuditComparison.Expression.
+func (a *AuditHooks) SetConditionTarget(target *[]AuditConditionNode) {
+ if a == nil {
+ return
+ }
+ if target != nil {
+ *target = nil
+ a.conditionActive = target
+ a.conditionGroupStack = nil
+ } else {
+ a.conditionActive = nil
+ a.conditionGroupStack = nil
+ a.currentBranchSource = ""
+ a.currentLocStart = parser.SourceLoc{}
+ a.currentLocEnd = parser.SourceLoc{}
+ a.currentLocSource = ""
+ }
+}
+
+// SetBranchSource stores the raw source of the branch being evaluated.
+// Called alongside SetConditionTarget so comparisons can reference it.
+func (a *AuditHooks) SetBranchSource(source string) {
+ if a != nil {
+ a.currentBranchSource = source
+ }
+}
+
+// AppendComparison appends a leaf comparison to the currently active collection.
+// Called by the ComparisonHook wired in audit.go.
+func (a *AuditHooks) AppendComparison(cmp AuditComparison) {
+ if a == nil || a.conditionActive == nil {
+ return
+ }
+ // For single-comparison branches the branch source IS the comparison expression.
+ // For compound expressions (and/or groups) this will be the full compound string
+ // which is still informative; sub-expression source is not tracked at this level.
+ if cmp.Expression == "" {
+ cmp.Expression = a.currentBranchSource
+ }
+ *a.conditionActive = append(*a.conditionActive, AuditConditionNode{Comparison: &cmp})
+}
+
+// BeginGroup is called before evaluating an and/or sub-expression's operands.
+// It suspends the current collection and starts a fresh child collection.
+func (a *AuditHooks) BeginGroup() {
+ if a == nil || a.conditionActive == nil {
+ return
+ }
+ a.conditionGroupStack = append(a.conditionGroupStack, a.conditionActive)
+ newItems := []AuditConditionNode{}
+ a.conditionActive = &newItems
+}
+
+// EndGroup is called after evaluating an and/or sub-expression.
+// It pops the suspended parent, wraps the collected children in an AuditGroup,
+// and appends the group as a node to the parent collection.
+func (a *AuditHooks) EndGroup(op string, result bool) {
+ if a == nil || len(a.conditionGroupStack) == 0 {
+ return
+ }
+ children := *a.conditionActive
+ n := len(a.conditionGroupStack)
+ parent := a.conditionGroupStack[n-1]
+ a.conditionGroupStack = a.conditionGroupStack[:n-1]
+ a.conditionActive = parent
+ group := &AuditGroup{Operator: op, Result: result, Items: children}
+ *a.conditionActive = append(*a.conditionActive, AuditConditionNode{Group: group})
+}
+
+// Depth returns the current block nesting depth. 0 = top-level.
+func (a *AuditHooks) Depth() int {
+ if a == nil {
+ return 0
+ }
+ return a.depth
+}
+
+// IterCount returns the number of loop iterations counted in the current loop.
+func (a *AuditHooks) IterCount() int {
+ if a == nil {
+ return 0
+ }
+ return a.iterCount
+}
+
+// IncrIterCount increments the per-loop iteration counter.
+func (a *AuditHooks) IncrIterCount() {
+ if a != nil {
+ a.iterCount++
+ }
+}
+
+// SuppressInner reports whether hook calls for inner nodes should be suppressed.
+func (a *AuditHooks) SuppressInner() bool {
+ if a == nil {
+ return false
+ }
+ return a.suppressInner
+}
+
+// SetSuppressInner sets the inner-suppression flag.
+func (a *AuditHooks) SetSuppressInner(v bool) {
+ if a != nil {
+ a.suppressInner = v
+ }
+}
+
+// ResetIterState resets iteration tracking for a new loop.
+func (a *AuditHooks) ResetIterState() {
+ if a != nil {
+ a.iterCount = 0
+ a.suppressInner = false
+ }
+}
+
+// RestoreIterState restores iteration tracking state saved before entering a nested loop.
+func (a *AuditHooks) RestoreIterState(iterCount int, suppressInner bool) {
+ if a != nil {
+ a.iterCount = iterCount
+ a.suppressInner = suppressInner
+ }
+}
+
+// SetFilterTarget sets the slice that the filter hook should write steps into.
+// Called by ObjectNode.render() before Evaluate(); pass nil to clear.
+func (a *AuditHooks) SetFilterTarget(target *[]FilterStep) {
+ if a != nil {
+ a.filterTarget = target
+ }
+}
+
+// FilterTarget returns the current filter capture slice (nil when not capturing).
+func (a *AuditHooks) FilterTarget() *[]FilterStep {
+ if a == nil {
+ return nil
+ }
+ return a.filterTarget
+}
diff --git a/render/autoescape_test.go b/render/autoescape_test.go
index abfd00ef..d200a838 100644
--- a/render/autoescape_test.go
+++ b/render/autoescape_test.go
@@ -73,6 +73,32 @@ func TestRenderEscapeFilter(t *testing.T) {
"",
)
})
+
+ // raw filter — equivalent alias for safe, from LiquidJS.
+ // Ported from LiquidJS: test/integration/liquid/output-escape.spec.ts
+ // "should skip escape for output with filter '| raw'"
+ t.Run("raw filter skips autoescape", func(t *testing.T) {
+ f(t,
+ `{{ input | raw }}`,
+ map[string]interface{}{
+ "input": "",
+ },
+ "",
+ )
+ })
+
+ t.Run("raw filter no-op without autoescape", func(t *testing.T) {
+ // When autoescape is not configured, raw is a no-op (still renders correctly).
+ buf.Reset()
+ cfg2 := NewConfig()
+ root, err := cfg2.Compile(`{{ input | raw }}`, parser.SourceLoc{})
+ require.NoError(t, err)
+ err = Render(root, buf, map[string]interface{}{
+ "input": "safe ",
+ }, cfg2)
+ require.NoError(t, err)
+ require.Equal(t, "safe ", buf.String())
+ })
}
// TestReplacerWriterIOContract verifies that replacerWriter.Write correctly
diff --git a/render/compile_audit.go b/render/compile_audit.go
new file mode 100644
index 00000000..7f8e160f
--- /dev/null
+++ b/render/compile_audit.go
@@ -0,0 +1,203 @@
+package render
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/osteele/liquid/parser"
+)
+
+// CompileAuditResult is the result of CompileAudit.
+// It separates the successfully-compiled tree from the parse-time diagnostics
+// and the optional fatal error that prevented a full AST.
+type CompileAuditResult struct {
+ // Node is the compiled render tree. Non-nil when FatalError == nil.
+ Node Node
+ // Diags are non-fatal parse diagnostics (syntax-error in {{ }} objects).
+ // Present even when FatalError != nil if they occurred before the fatal error.
+ Diags []parser.ParseDiag
+ // FatalError is the structural parse error (unclosed-tag or unexpected-tag),
+ // when the AST could not be completed. Node is nil in this case.
+ FatalError parser.Error
+}
+
+// CompileAudit parses source in error-recovering mode and compiles the result.
+//
+// Syntax errors in {{ expr }} objects are collected as non-fatal ParseDiags and
+// replaced with BrokenNode in the render tree. Tag/block compile errors are
+// also collected as non-fatal diagnostics.
+//
+// Only two structural errors remain fatal and set FatalError:
+// - unclosed-tag ({% if %} without {% endif %})
+// - unexpected-tag ({% endif %} without an opening {% if %})
+func (c *Config) CompileAudit(source string, loc parser.SourceLoc) CompileAuditResult {
+ root, diags, fatalErr := c.Config.ParseAudit(source, loc)
+ if fatalErr != nil {
+ return CompileAuditResult{Diags: diags, FatalError: fatalErr}
+ }
+
+ node, compileErr := c.compileNodeAudit(root, &diags)
+ if compileErr != nil {
+ // Structural compile error (should not normally happen after audit parse).
+ return CompileAuditResult{Diags: diags, FatalError: compileErr}
+ }
+
+ return CompileAuditResult{Node: node, Diags: diags}
+}
+
+// compileNodeAudit is like compileNode but catches non-fatal compile errors
+// (e.g. tag argument parse failures) and converts them to ParseDiags + BrokenNode.
+func (c *Config) compileNodeAudit(n parser.ASTNode, diags *[]parser.ParseDiag) (Node, parser.Error) { //nolint: gocyclo
+ switch n := n.(type) {
+ case *parser.ASTBlock:
+ body, err := c.compileNodesAudit(n.Body, diags)
+ if err != nil {
+ return nil, err
+ }
+
+ branches, err := c.compileBlocksAudit(n.Clauses, diags)
+ if err != nil {
+ return nil, err
+ }
+
+ cd, ok := c.findBlockDef(n.Name)
+ if !ok {
+ // Non-fatal: unknown block becomes BrokenNode.
+ *diags = append(*diags, parser.ParseDiag{
+ Code: "syntax-error",
+ Message: fmt.Sprintf("undefined tag %q", n.Name),
+ Tok: n.Token,
+ })
+ return &BrokenNode{n.Token}, nil
+ }
+
+ node := BlockNode{
+ Token: n.Token,
+ Body: body,
+ Clauses: branches,
+ }
+ if cd.parser != nil {
+ r, err := cd.parser(node)
+ if err != nil {
+ // Non-fatal: block arg parse failure → BrokenNode.
+ *diags = append(*diags, parser.ParseDiag{
+ Code: "syntax-error",
+ Message: err.Error(),
+ Tok: n.Token,
+ })
+ return &BrokenNode{n.Token}, nil
+ }
+ node.renderer = r
+ }
+ if analyzer, ok := c.findBlockAnalyzer(n.Name); ok {
+ node.Analysis = analyzer(node)
+ }
+
+ return &node, nil
+
+ case *parser.ASTRaw:
+ return &RawNode{sourcelessNode{}, n.Slices}, nil
+
+ case *parser.ASTSeq:
+ children, err := c.compileNodesAudit(n.Children, diags)
+ if err != nil {
+ return nil, err
+ }
+ return &SeqNode{sourcelessNode{}, children}, nil
+
+ case *parser.ASTTag:
+ if td, ok := c.FindTagDefinition(n.Name); ok {
+ f, err := td(n.Args)
+ if err != nil {
+ // Non-fatal: tag arg parse failure → BrokenNode.
+ *diags = append(*diags, parser.ParseDiag{
+ Code: "syntax-error",
+ Message: err.Error(),
+ Tok: n.Token,
+ })
+ return &BrokenNode{n.Token}, nil
+ }
+
+ var analysis NodeAnalysis
+ if analyzer, ok := c.findTagAnalyzer(n.Name); ok {
+ analysis = analyzer(n.Args)
+ }
+ return &TagNode{n.Token, f, analysis}, nil
+ }
+
+ if c.LaxTags {
+ noopFn := func(io.Writer, Context) error { return nil }
+ return &TagNode{n.Token, noopFn, NodeAnalysis{}}, nil
+ }
+
+ // Non-fatal: unknown tag → BrokenNode.
+ *diags = append(*diags, parser.ParseDiag{
+ Code: "syntax-error",
+ Message: fmt.Sprintf("undefined tag %q", n.Name),
+ Tok: n.Token,
+ })
+ return &BrokenNode{n.Token}, nil
+
+ case *parser.ASTText:
+ return &TextNode{n.Token}, nil
+
+ case *parser.ASTObject:
+ return &ObjectNode{n.Token, n.Expr}, nil
+
+ case *parser.ASTBroken:
+ // Already recorded as a diagnostic during parsing; just create BrokenNode.
+ return &BrokenNode{n.Token}, nil
+
+ case *parser.ASTTrim:
+ return &TrimNode{TrimDirection: n.TrimDirection, Greedy: c.Greedy}, nil
+
+ default:
+ panic(fmt.Errorf("un-compilable node type %T", n))
+ }
+}
+
+func (c *Config) compileBlocksAudit(blocks []*parser.ASTBlock, diags *[]parser.ParseDiag) ([]*BlockNode, parser.Error) {
+ out := make([]*BlockNode, 0, len(blocks))
+ for _, child := range blocks {
+ compiled, err := c.compileNodeAudit(child, diags)
+ if err != nil {
+ return nil, err
+ }
+ // compileNodeAudit never returns BrokenNode for a block that has a
+ // matching blockDef, but if it does (e.g. unknown block), skip casting.
+ if bn, ok := compiled.(*BlockNode); ok {
+ out = append(out, bn)
+ }
+ // BrokenNode for an unknown block clause: skip it in the clauses list.
+ }
+ return out, nil
+}
+
+func (c *Config) compileNodesAudit(nodes []parser.ASTNode, diags *[]parser.ParseDiag) ([]Node, parser.Error) {
+ out := make([]Node, 0, len(nodes))
+ for _, child := range nodes {
+ compiled, err := c.compileNodeAudit(child, diags)
+ if err != nil {
+ return nil, err
+ }
+
+ var trimLeft, trimRight bool
+ switch compiled.(type) {
+ case *TagNode, *BlockNode:
+ trimLeft = c.TrimTagLeft
+ trimRight = c.TrimTagRight
+ case *ObjectNode:
+ trimLeft = c.TrimOutputLeft
+ trimRight = c.TrimOutputRight
+ }
+
+ if trimLeft {
+ out = append(out, &TrimNode{TrimDirection: parser.Left, Greedy: c.Greedy})
+ }
+ out = append(out, compiled)
+ if trimRight {
+ out = append(out, &TrimNode{TrimDirection: parser.Right, Greedy: c.Greedy})
+ }
+ }
+ return out, nil
+}
diff --git a/render/compiler.go b/render/compiler.go
index e1b969db..e8095e51 100644
--- a/render/compiler.go
+++ b/render/compiler.go
@@ -2,6 +2,7 @@ package render
import (
"fmt"
+ "io"
"github.com/osteele/liquid/parser"
)
@@ -48,6 +49,9 @@ func (c *Config) compileNode(n parser.ASTNode) (Node, parser.Error) {
node.renderer = r
}
+ if analyzer, ok := c.findBlockAnalyzer(n.Name); ok {
+ node.Analysis = analyzer(node)
+ }
return &node, nil
case *parser.ASTRaw:
@@ -66,7 +70,18 @@ func (c *Config) compileNode(n parser.ASTNode) (Node, parser.Error) {
return nil, parser.Errorf(n, "%s", err)
}
- return &TagNode{n.Token, f}, nil
+ var analysis NodeAnalysis
+ if analyzer, ok := c.findTagAnalyzer(n.Name); ok {
+ analysis = analyzer(n.Args)
+ }
+
+ return &TagNode{n.Token, f, analysis}, nil
+ }
+
+ if c.LaxTags {
+ // Unknown tag → silent no-op when LaxTags is enabled.
+ noopFn := func(io.Writer, Context) error { return nil }
+ return &TagNode{n.Token, noopFn, NodeAnalysis{}}, nil
}
return nil, parser.Errorf(n, "undefined tag %q", n.Name)
@@ -74,8 +89,10 @@ func (c *Config) compileNode(n parser.ASTNode) (Node, parser.Error) {
return &TextNode{n.Token}, nil
case *parser.ASTObject:
return &ObjectNode{n.Token, n.Expr}, nil
+ case *parser.ASTBroken:
+ return &BrokenNode{n.Token}, nil
case *parser.ASTTrim:
- return &TrimNode{TrimDirection: n.TrimDirection}, nil
+ return &TrimNode{TrimDirection: n.TrimDirection, Greedy: c.Greedy}, nil
default:
panic(fmt.Errorf("un-compilable node type %T", n))
}
@@ -103,7 +120,23 @@ func (c *Config) compileNodes(nodes []parser.ASTNode) ([]Node, parser.Error) {
return nil, err
}
+ var trimLeft, trimRight bool
+ switch compiled.(type) {
+ case *TagNode, *BlockNode:
+ trimLeft = c.TrimTagLeft
+ trimRight = c.TrimTagRight
+ case *ObjectNode:
+ trimLeft = c.TrimOutputLeft
+ trimRight = c.TrimOutputRight
+ }
+
+ if trimLeft {
+ out = append(out, &TrimNode{TrimDirection: parser.Left, Greedy: c.Greedy})
+ }
out = append(out, compiled)
+ if trimRight {
+ out = append(out, &TrimNode{TrimDirection: parser.Right, Greedy: c.Greedy})
+ }
}
return out, nil
diff --git a/render/config.go b/render/config.go
index f9e6f168..a2100bf3 100644
--- a/render/config.go
+++ b/render/config.go
@@ -1,6 +1,9 @@
package render
import (
+ "context"
+ "sync"
+
"github.com/osteele/liquid/parser"
)
@@ -9,41 +12,155 @@ type Config struct {
parser.Config
grammar
- Cache map[string][]byte
+ Cache sync.Map // key: string, value: []byte — safe for concurrent use
StrictVariables bool
TemplateStore TemplateStore
+ // Globals are variables that are accessible in every rendering context,
+ // including isolated sub-contexts created by the {% render %} tag.
+ // They have lower priority than scope bindings: if a key exists in both,
+ // the scope binding wins.
+ Globals map[string]any
+
escapeReplacer Replacer
+ // globalFilter is a function applied to the value of every {{ }} expression
+ // before it is written to the output. Analogous to Ruby's global_filter option.
+ globalFilter func(any) (any, error)
+
// JekyllExtensions enables Jekyll-specific extensions to Liquid.
// When true, allows dot notation in assign tags (e.g., {% assign page.canonical_url = value %})
// This is not part of the Shopify Liquid standard but is used in Jekyll and Gojekyll.
// Default: false (strict Shopify Liquid compatibility)
JekyllExtensions bool
+
+ // TrimTagLeft, when true, automatically trims whitespace to the left of every
+ // {% tag %} and block open/close tag, as if each had a {%- prefix.
+ TrimTagLeft bool
+
+ // TrimTagRight, when true, automatically trims whitespace to the right of every
+ // {% tag %} and block open/close tag, as if each had a -%} suffix.
+ TrimTagRight bool
+
+ // TrimOutputLeft, when true, automatically trims whitespace to the left of every
+ // {{ output }} expression, as if each had a {{- prefix.
+ TrimOutputLeft bool
+
+ // TrimOutputRight, when true, automatically trims whitespace to the right of every
+ // {{ output }} expression, as if each had a -}} suffix.
+ TrimOutputRight bool
+
+ // Greedy controls whether whitespace trimming removes all consecutive blank
+ // characters including newlines (true, the default), or only trims inline
+ // blanks (space/tab) plus at most one newline (false).
+ Greedy bool
+
+ // SizeLimit, when positive, caps the total number of bytes written to the
+ // render output. A render that would exceed this limit fails with an error.
+ SizeLimit int64
+
+ // Audit, when non-nil, activates render audit/trace collection.
+ // Hook functions in the struct are called as the template is rendered.
+ // Must not be shared between concurrent renders.
+ Audit *AuditHooks
+
+ // Context is an optional Go context.Context that can be used to cancel a
+ // render in-flight (e.g. for per-request timeouts). When set, each node
+ // render checks for cancellation before proceeding.
+ Context context.Context
+
+ // ExceptionHandler, when non-nil, is called for each render-time error
+ // encountered during node evaluation. The function receives the error and
+ // returns a string to emit in place of the failed node. Returning an empty
+ // string suppresses the node output. This is analogous to Ruby Liquid's
+ // exception_renderer option.
+ ExceptionHandler func(error) string
+
+ // LaxTags, when true, silently ignores unknown tags instead of raising a
+ // parse error. Only the render-path skips unknown tags; analysis still
+ // treats them as no-ops.
+ LaxTags bool
+
+ // analysisInFlight tracks partial template filenames currently being compiled
+ // for static analysis. Used by the include tag analyzer to detect and break
+ // cycles (e.g., A includes B includes A). Safe for concurrent use.
+ analysisInFlight sync.Map
+}
+
+// MarkAnalysisInFlight records filename as currently being compiled for static
+// analysis. Returns true if it was already in-flight (cycle detected).
+func (c *Config) MarkAnalysisInFlight(filename string) (alreadyInFlight bool) {
+ _, alreadyInFlight = c.analysisInFlight.LoadOrStore(filename, true)
+ return
+}
+
+// ClearAnalysisInFlight removes filename from the in-flight set.
+func (c *Config) ClearAnalysisInFlight(filename string) {
+ c.analysisInFlight.Delete(filename)
}
type grammar struct {
- tags map[string]TagCompiler
- blockDefs map[string]*blockSyntax
+ tags map[string]TagCompiler
+ blockDefs map[string]*blockSyntax
+ tagAnalyzers map[string]TagAnalyzer
+ blockAnalyzers map[string]BlockAnalyzer
}
// NewConfig creates a new Settings.
// TemplateStore is initialized to a FileTemplateStore for backwards compatibility
+// AddTagAnalyzer registers a static analysis function for the named tag.
+func (c *Config) AddTagAnalyzer(name string, a TagAnalyzer) {
+ if c.tagAnalyzers == nil {
+ c.tagAnalyzers = map[string]TagAnalyzer{}
+ }
+ c.tagAnalyzers[name] = a
+}
+
+// AddBlockAnalyzer registers a static analysis function for the named block tag.
+func (c *Config) AddBlockAnalyzer(name string, a BlockAnalyzer) {
+ if c.blockAnalyzers == nil {
+ c.blockAnalyzers = map[string]BlockAnalyzer{}
+ }
+ c.blockAnalyzers[name] = a
+}
+
+func (g grammar) findTagAnalyzer(name string) (TagAnalyzer, bool) {
+ a, ok := g.tagAnalyzers[name]
+ return a, ok
+}
+
+func (g grammar) findBlockAnalyzer(name string) (BlockAnalyzer, bool) {
+ a, ok := g.blockAnalyzers[name]
+ return a, ok
+}
+
func NewConfig() Config {
g := grammar{
tags: map[string]TagCompiler{},
blockDefs: map[string]*blockSyntax{},
}
- return Config{
+ cfg := Config{
Config: parser.NewConfig(g),
grammar: g,
- Cache: map[string][]byte{},
TemplateStore: &FileTemplateStore{},
+ Greedy: true,
}
+ // Register "raw" unconditionally — it is a LiquidJS-standard filter that marks
+ // a value as safe (skips autoescape). When autoescape is off it is a no-op.
+ cfg.AddSafeFilter()
+ return cfg
}
func (c *Config) SetAutoEscapeReplacer(replacer Replacer) {
c.escapeReplacer = replacer
c.AddSafeFilter()
}
+
+// SetGlobalFilter sets a function that is applied to the evaluated value of every
+// {{ expression }} before it is written to the output. This is analogous to Ruby
+// Liquid's global_filter option. The function receives the evaluated value and
+// returns a transformed value or an error.
+func (c *Config) SetGlobalFilter(fn func(any) (any, error)) {
+ c.globalFilter = fn
+}
diff --git a/render/context.go b/render/context.go
index c35bdb59..3be03b75 100644
--- a/render/context.go
+++ b/render/context.go
@@ -12,6 +12,7 @@ import (
"github.com/osteele/liquid/parser"
"github.com/osteele/liquid/expressions"
+ "github.com/osteele/liquid/values"
)
// Context provides the rendering context for a tag renderer.
@@ -43,6 +44,11 @@ type Context interface {
// RenderFile parses and renders a template. It's used in the implementation of the {% include %} tag.
// RenderFile does not cache the compiled template.
RenderFile(string, map[string]any) (string, error)
+ // RenderFileIsolated parses and renders a template in an isolated scope.
+ // Unlike RenderFile, the rendered template cannot access variables from the calling context —
+ // only the explicitly provided bindings are available.
+ // It's used in the implementation of the {% render %} tag.
+ RenderFileIsolated(string, map[string]any) (string, error)
// Set updates the value of a variable in the current lexical environment.
// It's used in the implementation of the {% assign %} and {% capture %} tags.
Set(name string, value any)
@@ -59,6 +65,18 @@ type Context interface {
TagName() string
// WrapError creates a new error that records the source location from the current context.
WrapError(err error) Error
+ // WriteValue writes a value to the writer using the same rendering rules as {{ expr }}.
+ // nil renders as empty string, arrays render as space-joined elements, and autoescape
+ // is applied if configured on the engine.
+ WriteValue(w io.Writer, value any) error
+ // AuditHooks returns the active render audit hooks, or nil when no audit is in progress.
+ // Used internally by tag renderers to emit audit trace events.
+ AuditHooks() *AuditHooks
+ // TagLoc returns the source start and end locations of the current tag node.
+ // For block tags the block's SourceLoc/EndLoc are returned.
+ // Returns zero values when called outside a tag renderer.
+ // Used internally by tag renderers to report source locations in audit events.
+ TagLoc() (start, end parser.SourceLoc)
}
type TemplateStore interface {
@@ -94,6 +112,20 @@ func (c rendererContext) Errorf(format string, a ...any) Error {
}
}
+// sourceLoc returns the source location of the current node, preferring tag
+// nodes over block nodes. Returns a zero SourceLoc when neither is set.
+func (c rendererContext) sourceLoc() parser.SourceLoc {
+ if c.node != nil {
+ return c.node.SourceLoc
+ }
+
+ if c.cn != nil {
+ return c.cn.SourceLoc
+ }
+
+ return parser.SourceLoc{}
+}
+
func (c rendererContext) WrapError(err error) Error {
switch {
case c.node != nil:
@@ -105,6 +137,16 @@ func (c rendererContext) WrapError(err error) Error {
}
}
+func (c rendererContext) WriteValue(w io.Writer, value any) error {
+ if sv, isSafe := value.(values.SafeValue); isSafe {
+ return writeObject(w, sv.Value)
+ }
+ if replacer := c.ctx.config.escapeReplacer; replacer != nil {
+ w = &replacerWriter{replacer: replacer, w: w}
+ }
+ return writeObject(w, value)
+}
+
func (c rendererContext) Evaluate(expr expressions.Expression) (out any, err error) {
return c.ctx.Evaluate(expr)
}
@@ -145,17 +187,42 @@ func (c rendererContext) ExpandTagArg() (string, error) {
return args, nil
}
-// RenderBlock renders a node.
+// AuditHooks returns the active render audit hooks, or nil if no audit is active.
+func (c rendererContext) AuditHooks() *AuditHooks {
+ return c.ctx.config.Audit
+}
+
+// TagLoc returns the source start and end locations of the current tag or block node.
+func (c rendererContext) TagLoc() (start, end parser.SourceLoc) {
+ if c.node != nil {
+ return c.node.SourceLoc, c.node.EndLoc
+ }
+ if c.cn != nil {
+ return c.cn.SourceLoc, c.cn.EndLoc
+ }
+ return parser.SourceLoc{}, parser.SourceLoc{}
+}
+
+// RenderBlock renders a node. When audit is active, depth is incremented for
+// the duration of the block body so inner expressions get the correct depth.
func (c rendererContext) RenderBlock(w io.Writer, b *BlockNode) error {
+ if audit := c.ctx.config.Audit; audit != nil {
+ audit.depth++
+ defer func() { audit.depth-- }()
+ }
return c.ctx.RenderSequence(w, b.Body)
}
-// RenderChildren renders the current node's children.
+// RenderChildren renders the current node's children. When audit is active,
+// depth is incremented for the duration so inner expressions get the correct depth.
func (c rendererContext) RenderChildren(w io.Writer) Error {
if c.cn == nil {
return nil
}
-
+ if audit := c.ctx.config.Audit; audit != nil {
+ audit.depth++
+ defer func() { audit.depth-- }()
+ }
return c.ctx.RenderSequence(w, c.cn.Body)
}
@@ -163,8 +230,8 @@ func (c rendererContext) RenderFile(filename string, b map[string]any) (string,
source, err := c.ctx.config.TemplateStore.ReadTemplate(filename)
if err != nil && errors.Is(err, fs.ErrNotExist) {
// Is it cached?
- if cval, ok := c.ctx.config.Cache[filename]; ok {
- source = cval
+ if cval, ok := c.ctx.config.Cache.Load(filename); ok {
+ source = cval.([]byte)
} else {
return "", err
}
@@ -172,7 +239,7 @@ func (c rendererContext) RenderFile(filename string, b map[string]any) (string,
return "", err
}
- root, err := c.ctx.config.Compile(string(source), c.node.SourceLoc)
+ root, err := c.ctx.config.Compile(string(source), c.sourceLoc())
if err != nil {
return "", err
}
@@ -189,6 +256,37 @@ func (c rendererContext) RenderFile(filename string, b map[string]any) (string,
return buf.String(), nil
}
+// RenderFileIsolated parses and renders a template in an isolated scope.
+// The rendered template cannot access variables from the calling context —
+// only the explicitly provided bindings are available.
+// This is used by the {% render %} tag.
+func (c rendererContext) RenderFileIsolated(filename string, b map[string]any) (string, error) {
+ source, err := c.ctx.config.TemplateStore.ReadTemplate(filename)
+ if err != nil && errors.Is(err, fs.ErrNotExist) {
+ // Is it cached?
+ if cval, ok := c.ctx.config.Cache.Load(filename); ok {
+ source = cval.([]byte)
+ } else {
+ return "", err
+ }
+ } else if err != nil {
+ return "", err
+ }
+
+ root, err := c.ctx.config.Compile(string(source), c.sourceLoc())
+ if err != nil {
+ return "", err
+ }
+
+ // Only use passed bindings; do not inherit parent scope.
+ buf := new(bytes.Buffer)
+ if err := Render(root, buf, b, c.ctx.config); err != nil {
+ return "", err
+ }
+
+ return buf.String(), nil
+}
+
// InnerString renders the children to a string.
func (c rendererContext) InnerString() (string, error) {
buf := new(bytes.Buffer)
diff --git a/render/error.go b/render/error.go
index 6cc5a6a0..9381b699 100644
--- a/render/error.go
+++ b/render/error.go
@@ -1,6 +1,8 @@
package render
import (
+ "fmt"
+
"github.com/osteele/liquid/parser"
)
@@ -10,12 +12,141 @@ type Error interface {
LineNumber() int
Cause() error
Error() string
+ // Message returns the error message without the "Liquid error" prefix or
+ // location information.
+ Message() string
+ // MarkupContext returns the source text of the expression/tag that produced
+ // this error, e.g. "{{ product.price | divided_by: 0 }}".
+ MarkupContext() string
+}
+
+// RenderError is a render-time error with source location information.
+// Use errors.As to check whether a liquid error originates from rendering
+// (as opposed to parsing).
+type RenderError struct {
+ inner parser.Error
+}
+
+// Error builds the error string with the "Liquid error" prefix. This overrides
+// the inner parser.Error's "Liquid syntax error" prefix, since render-time
+// failures are not syntax errors.
+func (e *RenderError) Error() string {
+ line := ""
+ if n := e.inner.LineNumber(); n > 0 {
+ line = fmt.Sprintf(" (line %d)", n)
+ }
+ locative := ""
+ if p := e.inner.Path(); p != "" {
+ locative = " in " + p
+ } else if mc := e.inner.MarkupContext(); mc != "" {
+ locative = " in " + mc
+ }
+ return fmt.Sprintf("Liquid error%s: %s%s", line, e.inner.Message(), locative)
+}
+
+func (e *RenderError) Cause() error { return e.inner.Cause() }
+func (e *RenderError) Path() string { return e.inner.Path() }
+func (e *RenderError) LineNumber() int { return e.inner.LineNumber() }
+func (e *RenderError) Message() string { return e.inner.Message() }
+func (e *RenderError) MarkupContext() string { return e.inner.MarkupContext() }
+
+// Unwrap returns the inner parse-level error, enabling errors.As to walk the
+// chain and find causes such as ZeroDivisionError.
+func (e *RenderError) Unwrap() error { return e.inner }
+
+// UndefinedVariableError is returned when StrictVariables is enabled and a
+// template variable resolves to nil. The Name field contains the root variable
+// name (e.g. "user" for {{ user.name | upcase }}). BlockContext and BlockLine
+// are set to the innermost enclosing block tag source and line when the error
+// bubbles up through BlockNode.render.
+type UndefinedVariableError struct {
+ Name string
+ loc parser.Error
+ BlockContext string // e.g. "{% if cond %}"
+ BlockLine int // 1-based line of the enclosing block tag
+}
+
+func (e *UndefinedVariableError) Error() string {
+ line := ""
+ if e.loc.LineNumber() > 0 {
+ line = fmt.Sprintf(" (line %d)", e.loc.LineNumber())
+ }
+ // Primary locative: file path, then markup context of the {{ expr }}.
+ locative := ""
+ if e.loc.Path() != "" {
+ locative = " in " + e.loc.Path()
+ } else if mc := e.loc.MarkupContext(); mc != "" {
+ locative = " in " + mc
+ }
+ // Secondary context: the innermost enclosing block tag, if available.
+ blockCtx := ""
+ if e.BlockContext != "" {
+ if e.BlockLine > 0 {
+ blockCtx = fmt.Sprintf(" (inside %s, line %d)", e.BlockContext, e.BlockLine)
+ } else {
+ blockCtx = fmt.Sprintf(" (inside %s)", e.BlockContext)
+ }
+ }
+ return fmt.Sprintf("Liquid error%s: undefined variable %q%s%s", line, e.Name, locative, blockCtx)
}
+func (e *UndefinedVariableError) Cause() error { return nil }
+func (e *UndefinedVariableError) Path() string { return e.loc.Path() }
+func (e *UndefinedVariableError) LineNumber() int { return e.loc.LineNumber() }
+func (e *UndefinedVariableError) Message() string {
+ return fmt.Sprintf("undefined variable %q", e.Name)
+}
+func (e *UndefinedVariableError) MarkupContext() string { return e.loc.MarkupContext() }
+
+// Unwrap allows errors.As / errors.Is to find this error through a wrapping chain.
+func (e *UndefinedVariableError) Unwrap() error { return e.loc }
+
+// ArgumentError is returned by filters or tags that receive invalid arguments.
+// Return it from a filter or tag renderer; the render engine will wrap it with
+// source-location information so the full Error() string contains "Liquid error (line N): …".
+// Use errors.As to detect this in the error chain returned by Engine.ParseAndRender.
+type ArgumentError struct {
+ msg string
+}
+
+// NewArgumentError creates an ArgumentError with the given message.
+func NewArgumentError(msg string) *ArgumentError { return &ArgumentError{msg: msg} }
+
+func (e *ArgumentError) Error() string { return e.msg }
+
+// ContextError is returned when a context variable lookup or scope operation fails.
+// It surfaces through the render error chain; use errors.As to detect it.
+type ContextError struct {
+ msg string
+}
+
+// NewContextError creates a ContextError with the given message.
+func NewContextError(msg string) *ContextError { return &ContextError{msg: msg} }
+
+func (e *ContextError) Error() string { return e.msg }
+
func renderErrorf(loc parser.Locatable, format string, a ...any) Error {
- return parser.Errorf(loc, format, a...)
+ return &RenderError{parser.Errorf(loc, format, a...)}
}
func wrapRenderError(err error, loc parser.Locatable) Error {
- return parser.WrapError(err, loc)
+ if err == nil {
+ return nil
+ }
+ // UndefinedVariableError is already fully formed — preserve it as-is.
+ if ue, ok := err.(*UndefinedVariableError); ok {
+ return ue
+ }
+ // If already a RenderError, preserve it when:
+ // - it already has a file path (most specific possible), OR
+ // - it already has a line number (came from a specific inner node such as
+ // an ObjectNode or TagNode; a parent BlockNode must not overwrite it with
+ // a less-specific context such as "{% if … %}"), OR
+ // - the wrapping location itself has no useful information.
+ if re, ok := err.(*RenderError); ok {
+ if re.Path() != "" || re.LineNumber() > 0 || loc.SourceLocation().IsZero() {
+ return re
+ }
+ }
+ return &RenderError{parser.WrapError(err, loc)}
}
diff --git a/render/node_context.go b/render/node_context.go
index 685dbad9..2dd9da2c 100644
--- a/render/node_context.go
+++ b/render/node_context.go
@@ -20,7 +20,9 @@ type nodeContext struct {
func newNodeContext(scope map[string]any, c Config) nodeContext {
// The assign tag modifies the scope, so make a copy first.
// TODO this isn't really the right place for this.
- vars := make(map[string]any, len(scope))
+ // Globals have the lowest priority: scope bindings win over globals.
+ vars := make(map[string]any, len(c.Globals)+len(scope))
+ maps.Copy(vars, c.Globals)
maps.Copy(vars, scope)
ctx := nodeContext{bindings: vars, config: c}
@@ -28,6 +30,21 @@ func newNodeContext(scope map[string]any, c Config) nodeContext {
return ctx
}
+// SpawnIsolated creates a new node context that inherits the config but NOT
+// the parent bindings. Only the explicitly provided bindings are visible,
+// plus any globals defined on the engine config (which always propagate).
+// This is used by the {% render %} tag and layout/block inheritance, which
+// must not see variables from the calling scope.
+func (c nodeContext) SpawnIsolated(bindings map[string]any) nodeContext {
+ // Globals have the lowest priority; explicit bindings win.
+ vars := make(map[string]any, len(c.config.Globals)+len(bindings))
+ maps.Copy(vars, c.config.Globals)
+ maps.Copy(vars, bindings)
+ child := nodeContext{bindings: vars, config: c.config}
+ child.exprCtx = expressions.NewContext(vars, c.config.Config.Config)
+ return child
+}
+
// Evaluate evaluates an expression within the template context.
func (c nodeContext) Evaluate(expr expressions.Expression) (out any, err error) {
return expr.Evaluate(c.exprCtx)
diff --git a/render/nodes.go b/render/nodes.go
index 56e50ef3..46ea7615 100644
--- a/render/nodes.go
+++ b/render/nodes.go
@@ -21,6 +21,7 @@ type BlockNode struct {
renderer func(io.Writer, Context) error
Body []Node
Clauses []*BlockNode
+ Analysis NodeAnalysis
}
// RawNode holds the text between the start and end of a raw tag.
@@ -35,6 +36,7 @@ type TagNode struct {
parser.Token
renderer func(io.Writer, Context) error
+ Analysis NodeAnalysis
}
// TextNode is a text chunk, that is rendered verbatim.
@@ -49,6 +51,10 @@ type ObjectNode struct {
expr expressions.Expression
}
+// GetExpr returns the expression associated with this object node.
+// Used for static analysis.
+func (n *ObjectNode) GetExpr() expressions.Expression { return n.expr }
+
// SeqNode is a sequence of nodes.
type SeqNode struct {
sourcelessNode
@@ -60,6 +66,14 @@ type SeqNode struct {
type TrimNode struct {
sourcelessNode
parser.TrimDirection
+ Greedy bool // true = trim all whitespace; false = inline blanks + at most one newline
+}
+
+// BrokenNode is a render node whose source failed to parse or compile.
+// It renders as an empty string and returns no error. The failure was already
+// recorded as a Diagnostic in the parse-audit result.
+type BrokenNode struct {
+ parser.Token
}
// FIXME requiring this is a bad design
diff --git a/render/render.go b/render/render.go
index 433a4984..1875ca88 100644
--- a/render/render.go
+++ b/render/render.go
@@ -2,11 +2,11 @@
package render
import (
- "errors"
"fmt"
"io"
"reflect"
"strconv"
+ "strings"
"time"
"github.com/osteele/liquid/parser"
@@ -14,17 +14,36 @@ import (
"github.com/osteele/liquid/values"
)
+// sizeLimitWriter wraps an io.Writer and stops writing once the byte limit is reached.
+type sizeLimitWriter struct {
+ w io.Writer
+ limit int64
+ total int64
+}
+
+func (s *sizeLimitWriter) Write(p []byte) (int, error) {
+ s.total += int64(len(p))
+ if s.total > s.limit {
+ return 0, fmt.Errorf("render size limit of %d bytes exceeded", s.limit)
+ }
+ return s.w.Write(p)
+}
+
// Render renders the render tree.
func Render(node Node, w io.Writer, vars map[string]any, c Config) Error {
- tw := trimWriter{w: w}
+ var out io.Writer = w
+ if c.SizeLimit > 0 {
+ out = &sizeLimitWriter{w: w, limit: c.SizeLimit}
+ }
+ tw := trimWriter{w: out}
err := node.render(&tw, newNodeContext(vars, c))
if err != nil {
return err
}
- if _, err := tw.Flush(); err != nil {
- panic(err)
+ if _, flushErr := tw.Flush(); flushErr != nil {
+ return &RenderError{parser.WrapError(flushErr, invalidLoc)}
}
return nil
@@ -38,14 +57,25 @@ func (c nodeContext) RenderSequence(w io.Writer, seq []Node) Error {
}
for _, n := range seq {
+ if ctx := c.config.Context; ctx != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return &RenderError{parser.WrapError(ctxErr, invalidLoc)}
+ }
+ }
err := n.render(tw, c)
if err != nil {
+ if h := c.config.ExceptionHandler; h != nil {
+ if _, writeErr := io.WriteString(tw, h(err)); writeErr != nil {
+ return wrapRenderError(writeErr, n)
+ }
+ continue
+ }
return err
}
}
- if _, err := tw.Flush(); err != nil {
- panic(err)
+ if _, flushErr := tw.Flush(); flushErr != nil {
+ return &RenderError{parser.WrapError(flushErr, invalidLoc)}
}
return nil
@@ -65,6 +95,13 @@ func (n *BlockNode) render(w *trimWriter, ctx nodeContext) Error {
err := renderer(w, rendererContext{ctx, nil, n})
+ // Annotate UndefinedVariableError with the innermost enclosing block tag
+ // source, but only the first time (innermost wins over outer blocks).
+ if uve, ok := err.(*UndefinedVariableError); ok && uve.BlockContext == "" {
+ uve.BlockContext = n.Source
+ uve.BlockLine = n.SourceLoc.LineNo
+ }
+
return wrapRenderError(err, n)
}
@@ -80,14 +117,88 @@ func (n *RawNode) render(w *trimWriter, ctx nodeContext) Error {
}
func (n *ObjectNode) render(w *trimWriter, ctx nodeContext) Error {
+ // StrictVariables: check before evaluation so that undefined root
+ // variables are caught even when a filter chain transforms nil → "".
+ // A nil binding is treated the same as a missing key: both mean the
+ // variable has no usable value and should produce an UndefinedVariableError.
+ if ctx.config.StrictVariables {
+ vars := n.expr.Variables()
+ if len(vars) > 0 && len(vars[0]) > 0 {
+ root := vars[0][0]
+ v, exists := ctx.bindings[root]
+ if !exists || v == nil {
+ // Name is the root variable name only (e.g. "user", not "user.name"),
+ // matching Ruby Liquid's behaviour for dotted-path access.
+ locErr := parser.Errorf(n, "undefined variable %q", root)
+ uve := &UndefinedVariableError{Name: root, loc: locErr}
+ if audit := ctx.config.Audit; audit != nil {
+ if audit.OnError != nil {
+ audit.OnError(n.SourceLoc, n.EndLoc, n.Source, uve)
+ }
+ if audit.OnObject != nil && !audit.suppressInner {
+ parts := vars[0]
+ audit.OnObject(n.SourceLoc, n.EndLoc, n.Source, strings.Join(parts, "."), parts, nil, nil, audit.depth, uve)
+ }
+ }
+ return uve
+ }
+ }
+ }
+
+ // Set up filter pipeline capture if audit is active.
+ var auditPipeline []FilterStep
+ if audit := ctx.config.Audit; audit != nil && !audit.suppressInner {
+ audit.filterTarget = &auditPipeline
+ audit.currentLocStart = n.SourceLoc
+ audit.currentLocEnd = n.EndLoc
+ audit.currentLocSource = n.Source
+ }
+
value, err := ctx.Evaluate(n.expr)
+
+ if audit := ctx.config.Audit; audit != nil {
+ audit.filterTarget = nil
+ audit.currentLocStart = parser.SourceLoc{}
+ audit.currentLocEnd = parser.SourceLoc{}
+ audit.currentLocSource = ""
+ }
+
if err != nil {
+ if audit := ctx.config.Audit; audit != nil && audit.OnError != nil {
+ audit.OnError(n.SourceLoc, n.EndLoc, n.Source, err)
+ }
+ // Emit OnObject even on error (with nil value) so the audit layer can
+ // record the Expression with Error populated.
+ if audit := ctx.config.Audit; audit != nil && audit.OnObject != nil && !audit.suppressInner {
+ vars := n.expr.Variables()
+ name, parts := "", []string{}
+ if len(vars) > 0 && len(vars[0]) > 0 {
+ parts = vars[0]
+ name = strings.Join(parts, ".")
+ }
+ audit.OnObject(n.SourceLoc, n.EndLoc, n.Source, name, parts, nil, auditPipeline, audit.depth, err)
+ }
return wrapRenderError(err, n)
}
- if value == nil && ctx.config.StrictVariables {
- return wrapRenderError(errors.New("undefined variable"), n)
+ // Emit audit event for this object node (no error case).
+ if audit := ctx.config.Audit; audit != nil && audit.OnObject != nil && !audit.suppressInner {
+ vars := n.expr.Variables()
+ name, parts := "", []string{}
+ if len(vars) > 0 && len(vars[0]) > 0 {
+ parts = vars[0]
+ name = strings.Join(parts, ".")
+ }
+ audit.OnObject(n.SourceLoc, n.EndLoc, n.Source, name, parts, value, auditPipeline, audit.depth, nil)
+ }
+
+ if gf := ctx.config.globalFilter; gf != nil {
+ value, err = gf(value)
+ if err != nil {
+ return wrapRenderError(err, n)
+ }
}
+
if sv, isSafe := value.(values.SafeValue); isSafe {
err = writeObject(w, sv.Value)
} else {
@@ -111,8 +222,19 @@ func (n *ObjectNode) render(w *trimWriter, ctx nodeContext) Error {
func (n *SeqNode) render(w *trimWriter, ctx nodeContext) Error {
for _, c := range n.Children {
+ if ctxVal := ctx.config.Context; ctxVal != nil {
+ if ctxErr := ctxVal.Err(); ctxErr != nil {
+ return &RenderError{parser.WrapError(ctxErr, invalidLoc)}
+ }
+ }
err := c.render(w, ctx)
if err != nil {
+ if h := ctx.config.ExceptionHandler; h != nil {
+ if _, writeErr := io.WriteString(w, h(err)); writeErr != nil {
+ return wrapRenderError(writeErr, n)
+ }
+ continue
+ }
return err
}
}
@@ -130,13 +252,22 @@ func (n *TextNode) render(w *trimWriter, _ nodeContext) Error {
return wrapRenderError(err, n)
}
+// render for BrokenNode is a no-op: the failure was captured as a Diagnostic at parse time.
+func (n *BrokenNode) render(_ *trimWriter, _ nodeContext) Error { return nil }
+
func (n *TrimNode) render(w *trimWriter, _ nodeContext) Error {
if n.TrimDirection == parser.Left {
- return wrapRenderError(w.TrimLeft(), n)
- } else {
+ if n.Greedy {
+ return wrapRenderError(w.TrimLeft(), n)
+ }
+ return wrapRenderError(w.TrimLeftNonGreedy(), n)
+ }
+ if n.Greedy {
w.TrimRight()
- return nil
+ } else {
+ w.TrimRightNonGreedy()
}
+ return nil
}
// writeObject writes a value used in an object node
@@ -145,6 +276,10 @@ func writeObject(w io.Writer, value any) error {
if value == nil {
return nil
}
+ // EmptyDrop and BlankDrop always render as an empty string.
+ if _, ok := value.(values.LiquidSentinel); ok {
+ return nil
+ }
switch value := value.(type) {
case string:
@@ -167,13 +302,49 @@ func writeObject(w io.Writer, value any) error {
_, err := io.WriteString(w, value.Format("2006-01-02 15:04:05 -0700"))
return err
case []byte:
- _, err := w.Write(value)
+ _, err := io.WriteString(w, string(value))
return err
}
rt := reflect.ValueOf(value)
switch rt.Kind() {
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ _, err := io.WriteString(w, strconv.FormatInt(rt.Int(), 10))
+ return err
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ _, err := io.WriteString(w, strconv.FormatUint(rt.Uint(), 10))
+ return err
+ case reflect.Float32:
+ _, err := io.WriteString(w, strconv.FormatFloat(rt.Float(), 'f', -1, 32))
+ return err
+ case reflect.Float64:
+ _, err := io.WriteString(w, strconv.FormatFloat(rt.Float(), 'f', -1, 64))
+ return err
+ case reflect.Bool:
+ if rt.Bool() {
+ _, err := io.WriteString(w, "true")
+ return err
+ }
+ _, err := io.WriteString(w, "false")
+ return err
+ case reflect.String:
+ _, err := io.WriteString(w, rt.String())
+ return err
case reflect.Array, reflect.Slice:
+ // Byte arrays/slices (including defined types like type MyBytes []byte)
+ // are rendered as strings, not as space-joined numeric sequences.
+ if rt.Type().Elem().Kind() == reflect.Uint8 {
+ if rt.Kind() == reflect.Slice {
+ _, err := io.WriteString(w, string(rt.Bytes()))
+ return err
+ }
+ b := make([]byte, rt.Len())
+ for i := range rt.Len() {
+ b[i] = byte(rt.Index(i).Uint())
+ }
+ _, err := io.WriteString(w, string(b))
+ return err
+ }
for i := range rt.Len() {
item := rt.Index(i)
if item.IsValid() {
@@ -186,7 +357,14 @@ func writeObject(w io.Writer, value any) error {
return nil
case reflect.Ptr:
- return writeObject(w, reflect.ValueOf(value).Elem())
+ rv := reflect.ValueOf(value)
+ if rv.IsNil() {
+ return nil
+ }
+ return writeObject(w, rv.Elem().Interface())
+ case reflect.Chan, reflect.Func, reflect.Complex64, reflect.Complex128, reflect.UnsafePointer:
+ // Unsupported Go kinds surfaced directly (e.g. inside array elements).
+ return values.TypeError(fmt.Sprintf("unsupported type %s: chan, func, and complex values cannot be used in Liquid templates", rt.Type()))
default:
_, err := io.WriteString(w, fmt.Sprint(value))
return err
diff --git a/render/trimwriter.go b/render/trimwriter.go
index 2fad90e2..8643709d 100644
--- a/render/trimwriter.go
+++ b/render/trimwriter.go
@@ -7,23 +7,48 @@ import (
)
// A trimWriter provides whitespace control around a wrapped io.Writer.
-// The caller should call TrimLeft(bool) and TrimRight(bool) respectively
-// before and after processing a tag or expression, and Flush() at completion.
+// The caller should call TrimLeft/TrimRight (greedy) or TrimLeftNonGreedy/
+// TrimRightNonGreedy (non-greedy) respectively before and after processing a
+// tag or expression, and Flush() at completion.
type trimWriter struct {
- w io.Writer
- buf bytes.Buffer
- trim bool
+ w io.Writer
+ buf bytes.Buffer
+ trim bool // greedy right-trim pending
+ trimNonGreedy bool // non-greedy right-trim pending
}
-// Write writes b to the current buffer. If the trim flag is set,
+// isInlineBlank returns true for space and horizontal-tab characters only.
+// Used by non-greedy trim (mirrors LiquidJS INLINE_BLANK mask).
+func isInlineBlank(r rune) bool {
+ return r == ' ' || r == '\t'
+}
+
+// nonGreedyTrimLeft removes leading inline-blank (space/tab) characters from b,
+// then removes at most one trailing newline.
+func nonGreedyTrimLeft(b []byte) []byte {
+ i := 0
+ for i < len(b) && (b[i] == ' ' || b[i] == '\t') {
+ i++
+ }
+ if i < len(b) && b[i] == '\n' {
+ i++
+ }
+ return b[i:]
+}
+
+// Write writes b to the current buffer. If a trim flag is set,
// a prefix whitespace trim on b is performed before writing it to
-// the buffer and the trim flag is unset. If the trim flag was not
-// set, the current buffer is flushed before b is written.
+// the buffer and the trim flag is unset. If no trim flag was set,
+// the current buffer is flushed before b is written.
// Write only returns the bytes written to w during a flush.
func (tw *trimWriter) Write(b []byte) (n int, err error) {
if tw.trim {
b = bytes.TrimLeftFunc(b, unicode.IsSpace)
tw.trim = false
+ tw.trimNonGreedy = false
+ } else if tw.trimNonGreedy {
+ b = nonGreedyTrimLeft(b)
+ tw.trimNonGreedy = false
} else if n, err = tw.Flush(); err != nil {
return n, err
}
@@ -33,20 +58,41 @@ func (tw *trimWriter) Write(b []byte) (n int, err error) {
return
}
-// TrimLeft trims all whitespaces before the trim node, i.e. the whitespace
-// suffix of the current buffer. It then writes the current buffer to w and
-// resets the buffer.
+// TrimLeft trims all whitespace before the trim node, i.e. the whitespace
+// suffix of the current buffer (greedy). It then writes the current buffer to
+// w and resets the buffer.
func (tw *trimWriter) TrimLeft() error {
+ tw.trimNonGreedy = false
_, err := tw.w.Write(bytes.TrimRightFunc(tw.buf.Bytes(), unicode.IsSpace))
tw.buf.Reset()
return err
}
-// TrimRight sets the trim flag on the trimWriter. This will cause a prefix
-// whitespace trim on any subsequent write.
+// TrimLeftNonGreedy trims only trailing inline-blank (space/tab) characters
+// from the current buffer, then writes the buffer to w and resets it.
+func (tw *trimWriter) TrimLeftNonGreedy() error {
+ tw.trimNonGreedy = false
+ _, err := tw.w.Write(bytes.TrimRightFunc(tw.buf.Bytes(), isInlineBlank))
+ tw.buf.Reset()
+
+ return err
+}
+
+// TrimRight sets the greedy trim flag on the trimWriter. This will cause a
+// full (all whitespace) prefix trim on any subsequent write.
func (tw *trimWriter) TrimRight() {
tw.trim = true
+ tw.trimNonGreedy = false // greedy overrides non-greedy
+}
+
+// TrimRightNonGreedy sets the non-greedy trim flag when no greedy flag is
+// already pending. The next write will trim only leading inline blanks plus
+// at most one newline.
+func (tw *trimWriter) TrimRightNonGreedy() {
+ if !tw.trim {
+ tw.trimNonGreedy = true
+ }
}
// Flush flushes the current buffer into w.
diff --git a/render_audit_assignment_test.go b/render_audit_assignment_test.go
new file mode 100644
index 00000000..33df4384
--- /dev/null
+++ b/render_audit_assignment_test.go
@@ -0,0 +1,559 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// AssignmentTrace — Basic Attributes (A01–A09)
+// ============================================================================
+
+// A01 — {% assign x = "hello" %}: Variable="x", Value="hello".
+func TestRenderAudit_Assignment_A01_stringValue(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign greeting = "hello" %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Assignment.Variable != "greeting" {
+ t.Errorf("Variable=%q, want greeting", a.Assignment.Variable)
+ }
+ if a.Assignment.Value != "hello" {
+ t.Errorf("Value=%v, want hello", a.Assignment.Value)
+ }
+}
+
+// A02 — assign integer binding.
+func TestRenderAudit_Assignment_A02_intValue(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign count = 42 %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if sprintVal(a.Assignment.Value) != "42" {
+ t.Errorf("Value=%v, want 42", a.Assignment.Value)
+ }
+}
+
+// A03 — assign float literal.
+func TestRenderAudit_Assignment_A03_floatValue(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign pi = 3.14 %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if sprintVal(a.Assignment.Value) != "3.14" {
+ t.Errorf("Value=%v, want 3.14", a.Assignment.Value)
+ }
+}
+
+// A04 — assign true literal.
+func TestRenderAudit_Assignment_A04_boolTrue(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign flag = true %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Assignment.Value != true {
+ t.Errorf("Value=%v, want true", a.Assignment.Value)
+ }
+}
+
+// A05 — assign false literal.
+func TestRenderAudit_Assignment_A05_boolFalse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign flag = false %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Assignment.Value != false {
+ t.Errorf("Value=%v, want false", a.Assignment.Value)
+ }
+}
+
+// A06 — assign from an undefined variable: value is nil.
+// Note: "nil" and "empty" are reserved Liquid constants and cannot be used as
+// variable names or assignment values directly; assign from an undefined variable instead.
+func TestRenderAudit_Assignment_A06_nilValue(t *testing.T) {
+ // assigning from a variable not in the bindings yields nil.
+ tpl := mustParseAudit(t, "{% assign nilvariable = undefinedvar %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Assignment.Value != nil {
+ t.Errorf("Value=%v (type %T), want nil", a.Assignment.Value, a.Assignment.Value)
+ }
+}
+
+// A07 — assign from another variable: value resolves to the binding's value.
+func TestRenderAudit_Assignment_A07_fromVariable(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign copy = original %}")
+ result := auditOK(t, tpl, liquid.Bindings{"original": "source"}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Assignment.Value != "source" {
+ t.Errorf("Value=%v, want source", a.Assignment.Value)
+ }
+}
+
+// A08 — assign from a nested dot-access path.
+func TestRenderAudit_Assignment_A08_fromDotPath(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign title = page.title %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"page": map[string]any{"title": "My Page"}},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Assignment.Value != "My Page" {
+ t.Errorf("Value=%v, want %q", a.Assignment.Value, "My Page")
+ }
+}
+
+// A09 — simple assign has no Path (or empty Path).
+func TestRenderAudit_Assignment_A09_pathEmptyForSimpleAssign(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ // Path is for dot-notation on the target side; standard assign targets are flat.
+ // Either nil or empty is acceptable.
+ if len(a.Assignment.Path) > 0 {
+ t.Logf("Note: Path=%v for simple assign; informational only", a.Assignment.Path)
+ }
+}
+
+// ============================================================================
+// AssignmentTrace — Filter Pipeline (AP01–AP06)
+// ============================================================================
+
+// AP01 — assign with one filter (upcase): pipeline has one step.
+func TestRenderAudit_Assignment_AP01_oneFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign upper = name | upcase %}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "alice"}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if len(a.Assignment.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(a.Assignment.Pipeline))
+ }
+ step := a.Assignment.Pipeline[0]
+ if step.Filter != "upcase" {
+ t.Errorf("Filter=%q, want upcase", step.Filter)
+ }
+ if step.Input != "alice" {
+ t.Errorf("Input=%v, want alice", step.Input)
+ }
+ if step.Output != "ALICE" {
+ t.Errorf("Output=%v, want ALICE", step.Output)
+ }
+ if a.Assignment.Value != "ALICE" {
+ t.Errorf("Value=%v, want ALICE", a.Assignment.Value)
+ }
+}
+
+// AP02 — assign with two-filter chain (times | round).
+func TestRenderAudit_Assignment_AP02_twoFilterChain(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign discounted = price | times: 0.9 | round %}")
+ result := auditOK(t, tpl, liquid.Bindings{"price": 50}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if len(a.Assignment.Pipeline) != 2 {
+ t.Fatalf("Pipeline len=%d, want 2", len(a.Assignment.Pipeline))
+ }
+ if a.Assignment.Pipeline[0].Filter != "times" {
+ t.Errorf("Pipeline[0].Filter=%q, want times", a.Assignment.Pipeline[0].Filter)
+ }
+ if a.Assignment.Pipeline[1].Filter != "round" {
+ t.Errorf("Pipeline[1].Filter=%q, want round", a.Assignment.Pipeline[1].Filter)
+ }
+ // Output of first step cascades to input of second.
+ if a.Assignment.Pipeline[0].Output != a.Assignment.Pipeline[1].Input {
+ t.Errorf("pipeline chain broken: times.Output=%v != round.Input=%v",
+ a.Assignment.Pipeline[0].Output, a.Assignment.Pipeline[1].Input)
+ }
+}
+
+// AP03 — assign with array pipeline (sort | first).
+func TestRenderAudit_Assignment_AP03_arrayPipeline(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign smallest = nums | sort | first %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"nums": []int{3, 1, 2}},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if len(a.Assignment.Pipeline) != 2 {
+ t.Fatalf("Pipeline len=%d, want 2", len(a.Assignment.Pipeline))
+ }
+ if sprintVal(a.Assignment.Value) != "1" {
+ t.Errorf("Value=%v, want 1 (smallest after sort+first)", a.Assignment.Value)
+ }
+}
+
+// AP04 — assign using split filter: value is a slice.
+func TestRenderAudit_Assignment_AP04_splitResult(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign parts = csv | split: "," %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"csv": "a,b,c"},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if len(a.Assignment.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(a.Assignment.Pipeline))
+ }
+ switch v := a.Assignment.Value.(type) {
+ case []string:
+ if len(v) != 3 {
+ t.Errorf("Value len=%d, want 3", len(v))
+ }
+ case []any:
+ if len(v) != 3 {
+ t.Errorf("Value len=%d, want 3", len(v))
+ }
+ default:
+ t.Errorf("Value type=%T, want []string or []any", a.Assignment.Value)
+ }
+}
+
+// AP05 — assign without any filter: Pipeline is empty.
+func TestRenderAudit_Assignment_AP05_noPipeline(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hello" %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil || a.Assignment == nil {
+ t.Fatal("no assignment expression")
+ }
+ if len(a.Assignment.Pipeline) != 0 {
+ t.Errorf("Pipeline should be empty without filters, got %d steps", len(a.Assignment.Pipeline))
+ }
+}
+
+// AP06 — assign with a filter that fails: the tag is silently skipped, AssignmentTrace
+// still appears (with nil Value), no panic, no AuditError.
+// Note: filter errors inside {% assign %} are silently swallowed (no Diagnostic emitted);
+// filter errors inside {{ }} output tags DO produce Diagnostics.
+func TestRenderAudit_Assignment_AP06_filterError(t *testing.T) {
+ tpl := mustParseAudit(t, "{% assign result = 10 | divided_by: 0 %}")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ // No panic and no AuditError expected.
+ if ae != nil {
+ t.Errorf("unexpected AuditError: %v", ae)
+ }
+}
+
+// ============================================================================
+// AssignmentTrace — Source, Range, Depth (AR01–AR04)
+// ============================================================================
+
+// AR01 — Source is non-empty for assignments.
+// The Source field contains the expression body (not the full {% assign %} tag).
+func TestRenderAudit_Assignment_AR01_sourceNonEmpty(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hello" %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Source == "" {
+ t.Error("Source must be non-empty for assignment")
+ }
+}
+
+// AR02 — Range.Start.Line >= 1 and Column >= 1.
+func TestRenderAudit_Assignment_AR02_rangeValid(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "y" %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil {
+ t.Fatal("no assignment expression")
+ }
+ assertRangeValid(t, a.Range, "assignment Range")
+}
+
+// AR03 — assign inside an if block has Depth=1.
+func TestRenderAudit_Assignment_AR03_depthInsideIf(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if true %}{% assign x = "y" %}{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Depth != 1 {
+ t.Errorf("Depth=%d, want 1 (inside if)", a.Depth)
+ }
+}
+
+// AR04 — assign repeats once per for-loop iteration.
+func TestRenderAudit_Assignment_AR04_repeatsPerIteration(t *testing.T) {
+ tpl := mustParseAudit(t, `{% for item in items %}{% assign doubled = item | times: 2 %}{% endfor %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true, TraceAssignments: true},
+ )
+ assignExprs := allExprs(result.Expressions, liquid.KindAssignment)
+ if len(assignExprs) != 3 {
+ t.Errorf("assignment count=%d, want 3 (one per iteration)", len(assignExprs))
+ }
+}
+
+// ============================================================================
+// AssignmentTrace — Multiple Assigns (AM01–AM03)
+// ============================================================================
+
+// AM01 — three assigns in sequence: three expressions in order.
+func TestRenderAudit_Assignment_AM01_multipleAssignsOrdered(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign a = 1 %}{% assign b = 2 %}{% assign c = 3 %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ assigns := allExprs(result.Expressions, liquid.KindAssignment)
+ if len(assigns) != 3 {
+ t.Fatalf("assignment count=%d, want 3", len(assigns))
+ }
+ names := []string{
+ assigns[0].Assignment.Variable,
+ assigns[1].Assignment.Variable,
+ assigns[2].Assignment.Variable,
+ }
+ if names[0] != "a" || names[1] != "b" || names[2] != "c" {
+ t.Errorf("assignment variables=%v, want [a b c]", names)
+ }
+}
+
+// AM02 — assign then use: assignment appears before variable trace in the array.
+func TestRenderAudit_Assignment_AM02_assignBeforeVariable(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign msg = "hi" %}{{ msg }}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true, TraceVariables: true})
+ if len(result.Expressions) != 2 {
+ t.Fatalf("expected 2 expressions, got %d", len(result.Expressions))
+ }
+ if result.Expressions[0].Kind != liquid.KindAssignment {
+ t.Errorf("Expressions[0].Kind=%q, want assignment", result.Expressions[0].Kind)
+ }
+ if result.Expressions[1].Kind != liquid.KindVariable {
+ t.Errorf("Expressions[1].Kind=%q, want variable", result.Expressions[1].Kind)
+ }
+}
+
+// AM03 — reassigning the same variable produces two assignment traces.
+func TestRenderAudit_Assignment_AM03_reassign(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "first" %}{% assign x = "second" %}{{ x }}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true, TraceVariables: true})
+ assigns := allExprs(result.Expressions, liquid.KindAssignment)
+ if len(assigns) != 2 {
+ t.Fatalf("expected 2 assignment traces, got %d", len(assigns))
+ }
+ if assigns[0].Assignment.Value != "first" {
+ t.Errorf("first assign Value=%v, want first", assigns[0].Assignment.Value)
+ }
+ if assigns[1].Assignment.Value != "second" {
+ t.Errorf("second assign Value=%v, want second", assigns[1].Assignment.Value)
+ }
+ // Final variable value should be "second".
+ vars := allExprs(result.Expressions, liquid.KindVariable)
+ if len(vars) < 1 {
+ t.Fatal("expected variable expression after reassign")
+ }
+ if vars[0].Variable.Value != "second" {
+ t.Errorf("variable Value=%v, want second", vars[0].Variable.Value)
+ }
+}
+
+// ============================================================================
+// CaptureTrace — Basic Attributes (CP01–CP05)
+// ============================================================================
+
+// CP01 — simple capture: Variable and Value.
+func TestRenderAudit_Capture_CP01_simple(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture greeting %}Hello, world!{% endcapture %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil || c.Capture == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Capture.Variable != "greeting" {
+ t.Errorf("Variable=%q, want greeting", c.Capture.Variable)
+ }
+ if c.Capture.Value != "Hello, world!" {
+ t.Errorf("Value=%q, want %q", c.Capture.Value, "Hello, world!")
+ }
+}
+
+// CP02 — capture with an expression inside: Value contains rendered output.
+func TestRenderAudit_Capture_CP02_withExpression(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture msg %}Hello, {{ name }}!{% endcapture %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"name": "Alice"},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil || c.Capture == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Capture.Value != "Hello, Alice!" {
+ t.Errorf("Value=%q, want %q", c.Capture.Value, "Hello, Alice!")
+ }
+}
+
+// CP03 — capture with multiline content: entire rendered content is in Value.
+func TestRenderAudit_Capture_CP03_multiline(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture block %}\nline1\nline2\n{% endcapture %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil || c.Capture == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Capture.Value == "" {
+ t.Error("capture Value should be non-empty for multiline content")
+ }
+}
+
+// CP04 — empty capture: Value is empty string.
+func TestRenderAudit_Capture_CP04_empty(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture nothing %}{% endcapture %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil || c.Capture == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Capture.Value != "" {
+ t.Errorf("Value=%q, want empty string for empty capture", c.Capture.Value)
+ }
+}
+
+// CP05 — capture with an if tag inside: only the executed branch content is captured.
+func TestRenderAudit_Capture_CP05_withConditional(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture result %}{% if x %}yes{% else %}no{% endif %}{% endcapture %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil || c.Capture == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Capture.Value != "yes" {
+ t.Errorf("Value=%q, want yes", c.Capture.Value)
+ }
+}
+
+// ============================================================================
+// CaptureTrace — Source, Range, Depth (CPR01–CPR03)
+// ============================================================================
+
+// CPR01 — Source is the {% capture name %} header.
+func TestRenderAudit_Capture_CPR01_sourceNonEmpty(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture x %}hello{% endcapture %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Source == "" {
+ t.Error("Capture Source should be non-empty")
+ }
+}
+
+// CPR02 — Range.Start.Line >= 1.
+func TestRenderAudit_Capture_CPR02_rangeValid(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture x %}hello{% endcapture %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil {
+ t.Fatal("no capture expression")
+ }
+ assertRangeValid(t, c.Range, "capture Range")
+}
+
+// CPR03 — capture inside an if block has Depth=1.
+func TestRenderAudit_Capture_CPR03_depthInsideBlock(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if true %}{% capture x %}hello{% endcapture %}{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ c := firstExpr(result.Expressions, liquid.KindCapture)
+ if c == nil {
+ t.Fatal("no capture expression")
+ }
+ if c.Depth != 1 {
+ t.Errorf("Depth=%d, want 1 (inside if)", c.Depth)
+ }
+}
+
+// ============================================================================
+// CaptureTrace — Inner Traces (CPI01–CPI03)
+// ============================================================================
+
+// CPI01 — capture with {{ var }} inside: inner variable trace appears in expressions.
+func TestRenderAudit_Capture_CPI01_innerVariableTrace(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture msg %}{{ name }}{% endcapture %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"name": "Bob"},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ // Both a Capture and a Variable expression should appear.
+ captureExprs := allExprs(result.Expressions, liquid.KindCapture)
+ varExprs := allExprs(result.Expressions, liquid.KindVariable)
+ if len(captureExprs) == 0 {
+ t.Error("expected capture expression")
+ }
+ if len(varExprs) == 0 {
+ t.Error("expected variable expression inside capture body")
+ }
+}
+
+// CPI02 — capture with {% if %} inside: inner condition trace appears.
+func TestRenderAudit_Capture_CPI02_innerConditionTrace(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture msg %}{% if x %}yes{% endif %}{% endcapture %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"x": true},
+ liquid.AuditOptions{TraceAssignments: true, TraceConditions: true},
+ )
+ captureExprs := allExprs(result.Expressions, liquid.KindCapture)
+ condExprs := allExprs(result.Expressions, liquid.KindCondition)
+ if len(captureExprs) == 0 {
+ t.Error("expected capture expression")
+ }
+ if len(condExprs) == 0 {
+ t.Error("expected condition expression inside capture body")
+ }
+}
+
+// CPI03 — capture then use: the variable trace for {{ x }} shows the captured value.
+func TestRenderAudit_Capture_CPI03_capturedValueUsed(t *testing.T) {
+ tpl := mustParseAudit(t, "{% capture x %}Hello{% endcapture %}{{ x }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ assertOutput(t, result, "Hello")
+
+ vars := allExprs(result.Expressions, liquid.KindVariable)
+ for _, v := range vars {
+ if v.Variable != nil && v.Variable.Name == "x" {
+ if v.Variable.Value != "Hello" {
+ t.Errorf("{{ x }} Variable.Value=%v, want Hello (the captured value)", v.Variable.Value)
+ }
+ }
+ }
+}
diff --git a/render_audit_condition_test.go b/render_audit_condition_test.go
new file mode 100644
index 00000000..e03dce48
--- /dev/null
+++ b/render_audit_condition_test.go
@@ -0,0 +1,995 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// ConditionTrace — Branch Structure (C01–C10)
+// ============================================================================
+
+// C01 — {% if x %}...{% endif %} with no else: 1 branch, kind="if".
+func TestRenderAudit_Condition_C01_ifOnly(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) != 1 {
+ t.Fatalf("Branches len=%d, want 1 (if only)", len(c.Condition.Branches))
+ }
+ if c.Condition.Branches[0].Kind != "if" {
+ t.Errorf("Branches[0].Kind=%q, want %q", c.Condition.Branches[0].Kind, "if")
+ }
+}
+
+// C02 — {% if %}...{% else %}...{% endif %}: 2 branches: "if" + "else".
+func TestRenderAudit_Condition_C02_ifElse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) != 2 {
+ t.Fatalf("Branches len=%d, want 2", len(c.Condition.Branches))
+ }
+ if c.Condition.Branches[0].Kind != "if" {
+ t.Errorf("Branches[0].Kind=%q, want if", c.Condition.Branches[0].Kind)
+ }
+ if c.Condition.Branches[1].Kind != "else" {
+ t.Errorf("Branches[1].Kind=%q, want else", c.Condition.Branches[1].Kind)
+ }
+}
+
+// C03 — {% if %}...{% elsif %}...{% endif %}: 2 branches "if" + "elsif".
+func TestRenderAudit_Condition_C03_ifElsif(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}first{% elsif y %}second{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false, "y": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) != 2 {
+ t.Fatalf("Branches len=%d, want 2", len(c.Condition.Branches))
+ }
+ kinds := []string{c.Condition.Branches[0].Kind, c.Condition.Branches[1].Kind}
+ if kinds[0] != "if" || kinds[1] != "elsif" {
+ t.Errorf("kinds=%v, want [if elsif]", kinds)
+ }
+}
+
+// C04 — {% if %}...{% elsif %}...{% else %}...{% endif %}: 3 branches.
+func TestRenderAudit_Condition_C04_ifElsifElse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}a{% elsif y %}b{% else %}c{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false, "y": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) != 3 {
+ t.Fatalf("Branches len=%d, want 3", len(c.Condition.Branches))
+ }
+ kinds := []string{
+ c.Condition.Branches[0].Kind,
+ c.Condition.Branches[1].Kind,
+ c.Condition.Branches[2].Kind,
+ }
+ if kinds[0] != "if" || kinds[1] != "elsif" || kinds[2] != "else" {
+ t.Errorf("kinds=%v, want [if elsif else]", kinds)
+ }
+}
+
+// C05 — two elsif + else = 4 branches.
+func TestRenderAudit_Condition_C05_twoElsifElse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}a{% elsif y %}b{% elsif z %}c{% else %}d{% endif %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"x": false, "y": false, "z": true},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) != 4 {
+ t.Fatalf("Branches len=%d, want 4", len(c.Condition.Branches))
+ }
+}
+
+// C06 — {% unless x %}...{% endunless %}: 1 branch, kind="unless".
+func TestRenderAudit_Condition_C06_unlessOnly(t *testing.T) {
+ tpl := mustParseAudit(t, "{% unless disabled %}active{% endunless %}")
+ result := auditOK(t, tpl, liquid.Bindings{"disabled": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if c.Condition.Branches[0].Kind != "unless" {
+ t.Errorf("Branches[0].Kind=%q, want unless", c.Condition.Branches[0].Kind)
+ }
+}
+
+// C07 — {% unless %}...{% else %}...{% endunless %}: 2 branches.
+func TestRenderAudit_Condition_C07_unlessElse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% unless ok %}bad{% else %}good{% endunless %}")
+ result := auditOK(t, tpl, liquid.Bindings{"ok": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(c.Condition.Branches) != 2 {
+ t.Fatalf("Branches len=%d, want 2", len(c.Condition.Branches))
+ }
+ if c.Condition.Branches[0].Kind != "unless" {
+ t.Errorf("Branches[0].Kind=%q, want unless", c.Condition.Branches[0].Kind)
+ }
+ if c.Condition.Branches[1].Kind != "else" {
+ t.Errorf("Branches[1].Kind=%q, want else", c.Condition.Branches[1].Kind)
+ }
+}
+
+// C08 — {% case %}{% when "a" %}{% when "b" %}{% endcase %}: 2 when branches.
+func TestRenderAudit_Condition_C08_caseWhen(t *testing.T) {
+ tpl := mustParseAudit(t, `{% case x %}{% when "a" %}alpha{% when "b" %}beta{% endcase %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "a"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Skip("case/when does not yet produce a ConditionTrace")
+ }
+ if len(c.Condition.Branches) != 2 {
+ t.Fatalf("Branches len=%d, want 2 (when×2)", len(c.Condition.Branches))
+ }
+ for i, b := range c.Condition.Branches {
+ if b.Kind != "when" {
+ t.Errorf("Branches[%d].Kind=%q, want when", i, b.Kind)
+ }
+ }
+}
+
+// C09 — case with else: 2 when + 1 else = 3 branches.
+func TestRenderAudit_Condition_C09_caseWhenElse(t *testing.T) {
+ tpl := mustParseAudit(t, `{% case x %}{% when "a" %}alpha{% when "b" %}beta{% else %}other{% endcase %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "c"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Skip("case/when does not yet produce a ConditionTrace")
+ }
+ if len(c.Condition.Branches) != 3 {
+ t.Fatalf("Branches len=%d, want 3", len(c.Condition.Branches))
+ }
+ last := c.Condition.Branches[len(c.Condition.Branches)-1]
+ if last.Kind != "else" {
+ t.Errorf("last.Kind=%q, want else", last.Kind)
+ }
+}
+
+// ============================================================================
+// ConditionTrace — Executed flag (CE01–CE10)
+// ============================================================================
+
+// CE01 — if condition true → if branch executed.
+func TestRenderAudit_Condition_CE01_ifTrue_executed(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("if branch should have Executed=true when condition is true")
+ }
+}
+
+// CE02 — if false, else present → only else executed.
+func TestRenderAudit_Condition_CE02_ifFalse_elseExecuted(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) != 2 {
+ t.Fatal("expected 2 branches")
+ }
+ if c.Condition.Branches[0].Executed {
+ t.Error("if branch should have Executed=false")
+ }
+ if !c.Condition.Branches[1].Executed {
+ t.Error("else branch should have Executed=true")
+ }
+}
+
+// CE03 — if false, elsif true → only elsif executed.
+func TestRenderAudit_Condition_CE03_elsif_executed(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}a{% elsif y %}b{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false, "y": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) != 2 {
+ t.Fatal("expected 2 branches")
+ }
+ if c.Condition.Branches[0].Executed {
+ t.Error("if branch should not execute")
+ }
+ if !c.Condition.Branches[1].Executed {
+ t.Error("elsif branch should execute")
+ }
+}
+
+// CE04 — if false, elsif false, else → only else executed.
+func TestRenderAudit_Condition_CE04_else_executed(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}a{% elsif y %}b{% else %}c{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false, "y": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) != 3 {
+ t.Fatal("expected 3 branches")
+ }
+ if c.Condition.Branches[0].Executed {
+ t.Error("if branch should not execute")
+ }
+ if c.Condition.Branches[1].Executed {
+ t.Error("elsif branch should not execute")
+ }
+ if !c.Condition.Branches[2].Executed {
+ t.Error("else branch should execute")
+ }
+}
+
+// CE05 — unless false → unless body executes (Executed=true after inversion).
+func TestRenderAudit_Condition_CE05_unlessFalse_executes(t *testing.T) {
+ tpl := mustParseAudit(t, "{% unless disabled %}active{% endunless %}")
+ result := auditOK(t, tpl, liquid.Bindings{"disabled": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("unless branch should execute when condition is false (inverted)")
+ }
+}
+
+// CE06 — unless true → unless body does NOT execute.
+func TestRenderAudit_Condition_CE06_unlessTrue_notExecuted(t *testing.T) {
+ tpl := mustParseAudit(t, "{% unless disabled %}active{% endunless %}")
+ result := auditOK(t, tpl, liquid.Bindings{"disabled": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if c.Condition.Branches[0].Executed {
+ t.Error("unless branch should NOT execute when condition is true (inverted)")
+ }
+}
+
+// CE07 — case matches first when → first Executed=true.
+func TestRenderAudit_Condition_CE07_case_firstWhenExecuted(t *testing.T) {
+ tpl := mustParseAudit(t, `{% case x %}{% when "a" %}alpha{% when "b" %}beta{% else %}other{% endcase %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "a"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Skip("case/when does not yet produce a ConditionTrace")
+ }
+ if len(c.Condition.Branches) < 1 || !c.Condition.Branches[0].Executed {
+ t.Error("first when branch should be Executed=true when case matches")
+ }
+ for i := 1; i < len(c.Condition.Branches); i++ {
+ if c.Condition.Branches[i].Executed {
+ t.Errorf("Branches[%d].Executed should be false", i)
+ }
+ }
+}
+
+// CE08 — case matches second when → second Executed=true.
+func TestRenderAudit_Condition_CE08_case_secondWhenExecuted(t *testing.T) {
+ tpl := mustParseAudit(t, `{% case x %}{% when "a" %}alpha{% when "b" %}beta{% else %}other{% endcase %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "b"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Skip("case/when does not yet produce a ConditionTrace")
+ }
+ if len(c.Condition.Branches) < 2 || !c.Condition.Branches[1].Executed {
+ t.Error("second when branch should be Executed=true")
+ }
+}
+
+// CE09 — case no match, else → else Executed=true.
+func TestRenderAudit_Condition_CE09_case_elseExecuted(t *testing.T) {
+ tpl := mustParseAudit(t, `{% case x %}{% when "a" %}alpha{% else %}other{% endcase %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "z"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Skip("case/when does not yet produce a ConditionTrace")
+ }
+ last := c.Condition.Branches[len(c.Condition.Branches)-1]
+ if !last.Executed {
+ t.Error("else branch should execute when nothing matches")
+ }
+}
+
+// CE10 — case no match, no else → all Executed=false.
+func TestRenderAudit_Condition_CE10_case_noneExecuted(t *testing.T) {
+ tpl := mustParseAudit(t, `{% case x %}{% when "a" %}alpha{% when "b" %}beta{% endcase %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "z"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Skip("case/when does not yet produce a ConditionTrace")
+ }
+ for i, b := range c.Condition.Branches {
+ if b.Executed {
+ t.Errorf("Branches[%d].Executed should be false when no when matches", i)
+ }
+ }
+}
+
+// ============================================================================
+// ConditionTrace — ComparisonTrace (CC01–CC13)
+// ============================================================================
+
+// CC01 — operator ==.
+func TestRenderAudit_Condition_CC01_equalOp(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x == 1 %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison in if branch")
+ }
+ cmp := items[0].Comparison
+ if cmp.Operator != "==" {
+ t.Errorf("Operator=%q, want ==", cmp.Operator)
+ }
+ if sprintVal(cmp.Left) != "1" || sprintVal(cmp.Right) != "1" {
+ t.Errorf("Left=%v Right=%v, want both 1", cmp.Left, cmp.Right)
+ }
+ if !cmp.Result {
+ t.Error("Result should be true (1 == 1)")
+ }
+}
+
+// CC02 — operator !=.
+func TestRenderAudit_Condition_CC02_notEqualOp(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x != 2 %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Operator != "!=" {
+ t.Errorf("Operator=%q, want !=", items[0].Comparison.Operator)
+ }
+ if !items[0].Comparison.Result {
+ t.Error("Result should be true (1 != 2)")
+ }
+}
+
+// CC03 — operator >.
+func TestRenderAudit_Condition_CC03_greaterOp(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x > 5 %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": 10}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Operator != ">" {
+ t.Errorf("Operator=%q, want >", items[0].Comparison.Operator)
+ }
+}
+
+// CC04 — operator <.
+func TestRenderAudit_Condition_CC04_lessOp(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x < 5 %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": 3}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Operator != "<" {
+ t.Errorf("Operator=%q, want <", items[0].Comparison.Operator)
+ }
+}
+
+// CC05 — operator >=.
+func TestRenderAudit_Condition_CC05_gteOp(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x >= 10 %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": 10}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ cmp := c.Condition.Branches[0].Items[0].Comparison
+ if cmp == nil || cmp.Operator != ">=" {
+ t.Errorf("Operator=%v, want >=", cmp)
+ }
+ if !cmp.Result {
+ t.Error("Result should be true (10 >= 10)")
+ }
+}
+
+// CC06 — operator <=.
+func TestRenderAudit_Condition_CC06_lteOp(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x <= 5 %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": 5}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Operator != "<=" {
+ t.Errorf("Operator=%q, want <=", items[0].Comparison.Operator)
+ }
+}
+
+// CC07 — operator contains on an array.
+func TestRenderAudit_Condition_CC07_containsArray(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if arr contains "x" %}yes{% endif %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"arr": []string{"x", "y"}},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Operator != "contains" {
+ t.Errorf("Operator=%q, want contains", items[0].Comparison.Operator)
+ }
+ if !items[0].Comparison.Result {
+ t.Error("Result should be true (array contains x)")
+ }
+}
+
+// CC08 — operator contains on a string (substring check).
+func TestRenderAudit_Condition_CC08_containsString(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if str contains "ell" %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"str": "hello"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Operator != "contains" {
+ t.Errorf("Operator=%q, want contains", items[0].Comparison.Operator)
+ }
+ if !items[0].Comparison.Result {
+ t.Error("Result should be true (hello contains ell)")
+ }
+}
+
+// CC09 — Result=true when comparison holds.
+func TestRenderAudit_Condition_CC09_resultTrue(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x == "active" %}yes{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "active"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if !items[0].Comparison.Result {
+ t.Error("Result should be true")
+ }
+}
+
+// CC10 — Result=false when comparison fails.
+func TestRenderAudit_Condition_CC10_resultFalse(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if x == "active" %}yes{% else %}no{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"x": "inactive"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Result {
+ t.Error("Result should be false")
+ }
+}
+
+// CC11 — Left and Right carry typed values (int, string, bool).
+func TestRenderAudit_Condition_CC11_leftRightTypes(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if age > 18 %}adult{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"age": 25}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ cmp := items[0].Comparison
+ if sprintVal(cmp.Left) != "25" {
+ t.Errorf("Left=%v, want 25 (age binding)", cmp.Left)
+ }
+ if sprintVal(cmp.Right) != "18" {
+ t.Errorf("Right=%v, want 18 (literal)", cmp.Right)
+ }
+}
+
+// CC12 — ComparisonTrace.Expression field is non-empty.
+func TestRenderAudit_Condition_CC12_expressionFieldNonEmpty(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if score >= 60 %}pass{% endif %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"score": 75}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison")
+ }
+ if items[0].Comparison.Expression == "" {
+ t.Error("ComparisonTrace.Expression should be non-empty")
+ }
+}
+
+// CC13 — bare truthiness check: {% if x %} without explicit operator.
+func TestRenderAudit_Condition_CC13_truthiness(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": "something"}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ // Branch must be marked as executed since x is truthy.
+ if !c.Condition.Branches[0].Executed {
+ t.Error("if branch should be Executed=true for truthy x")
+ }
+ // Items may be empty (no explicit operator) or a single comparison — both are acceptable.
+}
+
+// ============================================================================
+// ConditionTrace — GroupTrace (CG01–CG09)
+// ============================================================================
+
+// CG01 — "and" with both sub-conditions true: GroupTrace.Operator="and", Result=true.
+// Note: GroupTrace.Items is not populated in the current implementation.
+func TestRenderAudit_Condition_CG01_andBothTrue(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a and b %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": true, "b": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("if branch should be executed (both a and b are true)")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 {
+ t.Fatal("no items in if branch")
+ }
+ g := items[0].Group
+ if g == nil {
+ t.Fatal("expected GroupTrace, got nil — items[0].Group is nil")
+ }
+ if g.Operator != "and" {
+ t.Errorf("Operator=%q, want and", g.Operator)
+ }
+ if !g.Result {
+ t.Error("GroupTrace.Result should be true (both sub-conditions true)")
+ }
+}
+
+// CG02 — "and" with one false → GroupTrace.Result=false.
+func TestRenderAudit_Condition_CG02_andOneFalse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a and b %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": true, "b": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Group == nil {
+ t.Fatal("expected group in if branch")
+ }
+ if items[0].Group.Result {
+ t.Error("GroupTrace.Result should be false (b is false)")
+ }
+}
+
+// CG03 — "or" both false → Result=false.
+func TestRenderAudit_Condition_CG03_orBothFalse(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a or b %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": false, "b": false}, liquid.AuditOptions{TraceConditions: true})
+ assertOutput(t, result, "no")
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Group == nil {
+ t.Fatal("expected group in if branch")
+ }
+ if items[0].Group.Operator != "or" {
+ t.Errorf("Operator=%q, want or", items[0].Group.Operator)
+ }
+ if items[0].Group.Result {
+ t.Error("GroupTrace.Result should be false")
+ }
+}
+
+// CG04 — "or" one true → Result=true.
+func TestRenderAudit_Condition_CG04_orOneTrue(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a or b %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": false, "b": true}, liquid.AuditOptions{TraceConditions: true})
+ assertOutput(t, result, "yes")
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Group == nil {
+ t.Fatal("expected group")
+ }
+ if !items[0].Group.Result {
+ t.Error("GroupTrace.Result should be true")
+ }
+}
+
+// CG05 — "a and b and c": all sub-conditions recorded (three items in group, or nested groups).
+func TestRenderAudit_Condition_CG05_andThree(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a and b and c %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": true, "b": true, "c": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 {
+ t.Fatal("no items")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("branch should execute (a and b and c all true)")
+ }
+ // The group might be 2-deep (a and (b and c)) or 3-wide — both acceptable.
+ // Just verify a group exists with Operator "and".
+ g := items[0].Group
+ if g == nil {
+ t.Skip("no GroupTrace emitted for 3-way and — may be implementation-specific")
+ }
+ if g.Operator != "and" {
+ t.Errorf("Operator=%q, want and", g.Operator)
+ }
+}
+
+// CG06 — "a or b or c": at least a group with Operator "or".
+func TestRenderAudit_Condition_CG06_orThree(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a or b or c %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": false, "b": false, "c": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("branch should execute (c is true)")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Group == nil {
+ t.Skip("no GroupTrace emitted for 3-way or — may be implementation-specific")
+ }
+ if items[0].Group.Operator != "or" {
+ t.Errorf("Operator=%q, want or", items[0].Group.Operator)
+ }
+}
+
+// CG07 — "a and b or c" mixed — Liquid evaluates right-to-left: `a and (b or c)`.
+// With a=true, b=true, c=false: `true and (true or false)` = true. Branch executes.
+func TestRenderAudit_Condition_CG07_andOrMixed(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a and b or c %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": true, "b": true, "c": false}, liquid.AuditOptions{TraceConditions: true})
+ assertOutput(t, result, "yes")
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("if branch should execute (a=true, b=true)")
+ }
+}
+
+// CG08 — group containing a comparison: GroupTrace exists with Operator="and".
+// Note: GroupTrace.Items is only populated when both sides are explicit comparisons.
+// With truthiness (bare variable) on one side, Items may be empty.
+func TestRenderAudit_Condition_CG08_groupContainsComparisons(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if age >= 18 and active %}yes{% endif %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"age": 20, "active": true},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ assertOutput(t, result, "yes")
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ if !c.Condition.Branches[0].Executed {
+ t.Error("if branch should execute (age=20 >= 18 and active=true)")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Group == nil {
+ t.Fatal("expected GroupTrace in if branch items")
+ }
+ g := items[0].Group
+ if g.Operator != "and" {
+ t.Errorf("GroupTrace.Operator=%q, want \"and\"", g.Operator)
+ }
+ if !g.Result {
+ t.Error("GroupTrace.Result should be true (age>=18 and active=true)")
+ }
+}
+
+// CG09 — group with two explicit comparisons: GroupTrace.Items contains both sub-comparisons.
+// This validates the full GroupTrace.Items population as promised in the spec.
+func TestRenderAudit_Condition_CG09_groupItemsBothComparisons(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x >= 10 and y < 5 %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"x": 15, "y": 3},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ assertOutput(t, result, "yes")
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no branches")
+ }
+ items := c.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Group == nil {
+ t.Fatal("expected GroupTrace in if branch")
+ }
+ g := items[0].Group
+ if g.Operator != "and" {
+ t.Errorf("GroupTrace.Operator=%q, want \"and\"", g.Operator)
+ }
+ if !g.Result {
+ t.Error("GroupTrace.Result should be true (15>=10 and 3<5)")
+ }
+ // GroupTrace.Items must have exactly 2 children (one per comparison).
+ if len(g.Items) != 2 {
+ t.Fatalf("GroupTrace.Items len=%d, want 2", len(g.Items))
+ }
+ // First child: x >= 10
+ cmp0 := g.Items[0].Comparison
+ if cmp0 == nil {
+ t.Fatal("GroupTrace.Items[0] should be a Comparison, got Group")
+ }
+ if cmp0.Operator != ">=" {
+ t.Errorf("Items[0].Operator=%q, want >=", cmp0.Operator)
+ }
+ if cmp0.Left != 15 {
+ t.Errorf("Items[0].Left=%v, want 15", cmp0.Left)
+ }
+ if cmp0.Right != 10 {
+ t.Errorf("Items[0].Right=%v, want 10", cmp0.Right)
+ }
+ if !cmp0.Result {
+ t.Error("Items[0].Result should be true (15 >= 10)")
+ }
+ // Second child: y < 5
+ cmp1 := g.Items[1].Comparison
+ if cmp1 == nil {
+ t.Fatal("GroupTrace.Items[1] should be a Comparison, got Group")
+ }
+ if cmp1.Operator != "<" {
+ t.Errorf("Items[1].Operator=%q, want <", cmp1.Operator)
+ }
+ if cmp1.Left != 3 {
+ t.Errorf("Items[1].Left=%v, want 3", cmp1.Left)
+ }
+ if cmp1.Right != 5 {
+ t.Errorf("Items[1].Right=%v, want 5", cmp1.Right)
+ }
+ if !cmp1.Result {
+ t.Error("Items[1].Result should be true (3 < 5)")
+ }
+}
+
+// ============================================================================
+// ConditionTrace — Branch Range and Source (CB01–CB05)
+// ============================================================================
+
+// CB01 — Branch[0].Range is valid for the if header.
+func TestRenderAudit_Condition_CB01_branchRangeValid(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x > 0 %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no condition branches")
+ }
+ assertRangeValid(t, c.Condition.Branches[0].Range, "branch[0].Range")
+}
+
+// CB02 — else branch Range is valid.
+func TestRenderAudit_Condition_CB02_elseBranchRange(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% else %}no{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) != 2 {
+ t.Fatal("expected 2 branches")
+ }
+ assertRangeValid(t, c.Condition.Branches[1].Range, "else branch Range")
+}
+
+// CB03 — elsif branch Range is valid and points to its own line.
+func TestRenderAudit_Condition_CB03_elsifBranchRange(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}a{% elsif y %}b{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": false, "y": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 2 {
+ t.Fatal("expected 2 branches (if + elsif)")
+ }
+ assertRangeValid(t, c.Condition.Branches[1].Range, "elsif branch Range")
+}
+
+// CB04 — ConditionTrace Expression.Range is valid.
+func TestRenderAudit_Condition_CB04_expressionRangeValid(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil {
+ t.Fatal("no condition expression")
+ }
+ assertRangeValid(t, c.Range, "condition expression Range")
+}
+
+// CB05 — ConditionTrace Expression.Source is non-empty.
+func TestRenderAudit_Condition_CB05_expressionSourceNonEmpty(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil {
+ t.Fatal("no condition expression")
+ }
+ if c.Source == "" {
+ t.Error("ConditionTrace Expression.Source should be non-empty")
+ }
+}
+
+// ============================================================================
+// ConditionTrace — Depth (CD01–CD03)
+// ============================================================================
+
+// CD01 — top-level condition has Depth=0.
+func TestRenderAudit_Condition_CD01_depthZero(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}yes{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": true}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil {
+ t.Fatal("no condition expression")
+ }
+ if c.Depth != 0 {
+ t.Errorf("Depth=%d, want 0 for top-level condition", c.Depth)
+ }
+}
+
+// CD02 — condition inside a for block has Depth=1.
+func TestRenderAudit_Condition_CD02_depthInsideFor(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{% if item > 1 %}big{% endif %}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{2}},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil {
+ t.Fatal("no condition expression")
+ }
+ if c.Depth != 1 {
+ t.Errorf("Depth=%d, want 1 (inside for)", c.Depth)
+ }
+}
+
+// CD03 — nested if inside if has Depth=2.
+func TestRenderAudit_Condition_CD03_depthNestedIf(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if true %}{% if true %}inner{% endif %}{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceConditions: true})
+ conditions := allExprs(result.Expressions, liquid.KindCondition)
+ if len(conditions) < 2 {
+ t.Fatalf("expected 2 condition expressions (outer + inner), got %d", len(conditions))
+ }
+ // Outer has Depth=0, inner has Depth=1.
+ depths := make([]int, len(conditions))
+ for i, c := range conditions {
+ depths[i] = c.Depth
+ }
+ found1 := false
+ for _, d := range depths {
+ if d == 1 {
+ found1 = true
+ }
+ }
+ if !found1 {
+ t.Errorf("depths=%v; expected at least one condition at Depth=1 (inner if)", depths)
+ }
+}
+
+// ============================================================================
+// ConditionTrace — error in condition (CR01)
+// ============================================================================
+
+// CR01 — undefined variable in condition: with StrictVariables, the comparison silently
+// evaluates to false (nil compared to 1 returns false) and the else branch runs.
+// No diagnostic is emitted for undefined variables used in comparisons (only for output tags).
+func TestRenderAudit_Condition_CR01_undefinedVarInCondition(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if ghost == 1 %}yes{% else %}no{% endif %}")
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceConditions: true},
+ liquid.WithStrictVariables(),
+ )
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ _ = ae // may or may not be nil depending on strict mode handling
+ // The else branch runs because ghost (undefined) != 1.
+ assertOutput(t, result, "no")
+ // The condition trace should show the if branch NOT executed.
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil || len(c.Condition.Branches) < 1 {
+ t.Fatal("no condition trace")
+ }
+ if c.Condition.Branches[0].Executed {
+ t.Error("if branch (ghost == 1) should NOT be executed (ghost is undefined/nil)")
+ }
+}
+
+// ============================================================================
+// else branch Items are empty (no explicit comparison)
+// ============================================================================
+
+// Extra: else branch has no Items.
+func TestRenderAudit_Condition_ElseBranchNoItems(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x > 10 %}big{% else %}small{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 5}, liquid.AuditOptions{TraceConditions: true})
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil || c.Condition == nil {
+ t.Fatal("no condition expression")
+ }
+ for _, b := range c.Condition.Branches {
+ if b.Kind == "else" && len(b.Items) > 0 {
+ t.Errorf("else branch should have 0 Items, got %d", len(b.Items))
+ }
+ }
+}
+
+// Extra: only the executed branch's inner expressions appear in the expressions array.
+func TestRenderAudit_Condition_OnlyExecutedBranchInnerExprs(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}{{ a }}{% else %}{{ b }}{% endif %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"x": true, "a": "yes", "b": "no"},
+ liquid.AuditOptions{TraceConditions: true, TraceVariables: true},
+ )
+ vars := allExprs(result.Expressions, liquid.KindVariable)
+ for _, v := range vars {
+ if v.Variable != nil && v.Variable.Name == "b" {
+ t.Error("variable 'b' should not be traced — it's in the unexecuted branch")
+ }
+ }
+}
diff --git a/render_audit_diagnostics_test.go b/render_audit_diagnostics_test.go
new file mode 100644
index 00000000..d783e3e1
--- /dev/null
+++ b/render_audit_diagnostics_test.go
@@ -0,0 +1,506 @@
+package liquid_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Diagnostics — Runtime Errors (D01–D16)
+// ============================================================================
+
+// D01 — undefined variable with StrictVariables: code="undefined-variable", severity=warning.
+func TestRenderAudit_Diag_D01_undefinedVariable(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ ghost }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{}, liquid.WithStrictVariables())
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "undefined-variable")
+ if d == nil {
+ t.Fatal("expected undefined-variable diagnostic")
+ }
+ if d.Severity != liquid.SeverityWarning {
+ t.Errorf("Severity=%q, want warning", d.Severity)
+ }
+ if !strings.Contains(d.Message, "ghost") {
+ t.Errorf("Message=%q should mention the variable name 'ghost'", d.Message)
+ }
+}
+
+// D02 — nested path undefined with StrictVariables.
+func TestRenderAudit_Diag_D02_undefinedPath(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a.b }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{}, liquid.WithStrictVariables())
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "undefined-variable")
+ if d == nil {
+ t.Fatal("expected undefined-variable diagnostic for nested path a.b")
+ }
+}
+
+// D03 — multiple undefined variables each produce a diagnostic.
+func TestRenderAudit_Diag_D03_multipleUndefined(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}{{ y }}{{ z }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{}, liquid.WithStrictVariables())
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ undef := allDiags(result.Diagnostics, "undefined-variable")
+ if len(undef) != 3 {
+ t.Errorf("undefined-variable diagnostic count=%d, want 3 (one per undefined var)", len(undef))
+ }
+}
+
+// D04 — divided_by: 0 → "argument-error", severity=error.
+func TestRenderAudit_Diag_D04_dividedByZero(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 10 | divided_by: 0 }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "argument-error")
+ if d == nil {
+ t.Fatal("expected argument-error diagnostic for divided_by: 0")
+ }
+ if d.Severity != liquid.SeverityError {
+ t.Errorf("Severity=%q, want error", d.Severity)
+ }
+}
+
+// D05 — modulo: 0 → "argument-error".
+func TestRenderAudit_Diag_D05_moduloZero(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 10 | modulo: 0 }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "argument-error")
+ if d == nil {
+ t.Fatal("expected argument-error diagnostic for modulo: 0")
+ }
+ if d.Severity != liquid.SeverityError {
+ t.Errorf("Severity=%q, want error", d.Severity)
+ }
+}
+
+// D06 — argument-error message is descriptive.
+func TestRenderAudit_Diag_D06_argumentErrorMessageDescriptive(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 10 | divided_by: 0 }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ d := firstDiag(result.Diagnostics, "argument-error")
+ if d == nil {
+ t.Fatal("expected argument-error diagnostic")
+ }
+ if d.Message == "" {
+ t.Error("argument-error diagnostic Message should not be empty")
+ }
+}
+
+// D07 — type mismatch string vs int with ==: "type-mismatch", severity=warning.
+func TestRenderAudit_Diag_D07_typeMismatchStringInt(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if status == 1 %}yes{% endif %}`)
+ result, _ := tpl.RenderAudit(liquid.Bindings{"status": "active"}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "type-mismatch")
+ if d == nil {
+ t.Fatal("expected type-mismatch diagnostic")
+ }
+ if d.Severity != liquid.SeverityWarning {
+ t.Errorf("Severity=%q, want warning", d.Severity)
+ }
+}
+
+// D08 — type mismatch with > operator: diagnostic mentions the operator.
+func TestRenderAudit_Diag_D08_typeMismatchWithGT(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if name > 5 %}yes{% endif %}`)
+ result, _ := tpl.RenderAudit(liquid.Bindings{"name": "alice"}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "type-mismatch")
+ if d == nil {
+ t.Fatal("expected type-mismatch diagnostic for string > int")
+ }
+ if d.Message == "" {
+ t.Error("type-mismatch Message should not be empty")
+ }
+}
+
+// D09 — nil variable in comparison without path: no diagnostic (normal Liquid behavior).
+func TestRenderAudit_Diag_D09_nilComparisonNoWarning(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if nil_var == 1 %}yes{% else %}no{% endif %}")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if ae != nil {
+ // Only argument errors should fire here, not undefined-variable without StrictVariables.
+ }
+ // type-mismatch might fire here (nil vs int). But there should be no undefined-variable.
+ undef := firstDiag(result.Diagnostics, "undefined-variable")
+ if undef != nil {
+ t.Error("undefined-variable should not appear without StrictVariables for nil comparison")
+ }
+}
+
+// D10 — for over int → "not-iterable", severity=warning.
+func TestRenderAudit_Diag_D10_notIterableInt(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in orders %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{"orders": 42}, liquid.AuditOptions{})
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic")
+ }
+ if d.Severity != liquid.SeverityWarning {
+ t.Errorf("Severity=%q, want warning", d.Severity)
+ }
+}
+
+// D11 — for over bool → "not-iterable", severity=warning.
+func TestRenderAudit_Diag_D11_notIterableBool(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in flag %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{"flag": true}, liquid.AuditOptions{})
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic for bool")
+ }
+}
+
+// D12 — for over string → "not-iterable", severity=warning.
+func TestRenderAudit_Diag_D12_notIterableString(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in status %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{"status": "pending"}, liquid.AuditOptions{})
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic for string")
+ }
+}
+
+// D13 — nil intermediate in chained path: "nil-dereference", severity=warning.
+func TestRenderAudit_Diag_D13_nilDereference(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ customer.address.city }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"customer": map[string]any{"address": nil}},
+ liquid.AuditOptions{},
+ )
+ d := firstDiag(result.Diagnostics, "nil-dereference")
+ if d == nil {
+ t.Fatal("expected nil-dereference diagnostic")
+ }
+ if d.Severity != liquid.SeverityWarning {
+ t.Errorf("Severity=%q, want warning", d.Severity)
+ }
+}
+
+// D14 — deep nil in chained path: "nil-dereference" still fires.
+func TestRenderAudit_Diag_D14_deepNilDereference(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a.b.c.d }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"a": map[string]any{"b": nil}},
+ liquid.AuditOptions{},
+ )
+ d := firstDiag(result.Diagnostics, "nil-dereference")
+ if d == nil {
+ t.Fatal("expected nil-dereference diagnostic for deep nil path")
+ }
+}
+
+// D15 — simple nil variable (no chaining): no diagnostic.
+func TestRenderAudit_Diag_D15_simpleNilNoWarning(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ nil_var }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{"nil_var": nil}, liquid.AuditOptions{})
+ // nil render is normal Liquid behavior; no diagnostic expected.
+ nilDeref := firstDiag(result.Diagnostics, "nil-dereference")
+ if nilDeref != nil {
+ t.Error("nil simple variable should NOT produce nil-dereference diagnostic")
+ }
+ assertOutput(t, result, "")
+}
+
+// D16 — nil variable in condition without chaining: no diagnostic.
+func TestRenderAudit_Diag_D16_nilInConditionNoWarning(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if nil_var %}yes{% else %}no{% endif %}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{"nil_var": nil}, liquid.AuditOptions{})
+ nilDeref := firstDiag(result.Diagnostics, "nil-dereference")
+ if nilDeref != nil {
+ t.Error("nil variable in simple condition should NOT produce nil-dereference diagnostic")
+ }
+ assertOutput(t, result, "no")
+}
+
+// ============================================================================
+// Diagnostics — Range and Source fields (DR01–DR04)
+// ============================================================================
+
+// DR01 — all diagnostics have Range.Start.Line >= 1.
+func TestRenderAudit_Diag_DR01_allHaveValidLine(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}{{ y }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{}, liquid.WithStrictVariables())
+ for i, d := range result.Diagnostics {
+ if d.Range.Start.Line < 1 {
+ t.Errorf("Diagnostics[%d].Range.Start.Line=%d, want >= 1", i, d.Range.Start.Line)
+ }
+ }
+}
+
+// DR02 — diagnostic Source is non-empty.
+func TestRenderAudit_Diag_DR02_sourceNonEmpty(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 10 | divided_by: 0 }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ d := firstDiag(result.Diagnostics, "argument-error")
+ if d == nil {
+ t.Fatal("expected argument-error diagnostic")
+ }
+ if d.Source == "" {
+ t.Error("Diagnostic.Source should not be empty")
+ }
+}
+
+// DR03 — diagnostic on line 5 has Range.Start.Line=5.
+func TestRenderAudit_Diag_DR03_lineNumber(t *testing.T) {
+ tpl := mustParseAudit(t, "line1\nline2\nline3\nline4\n{{ ghost }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{}, liquid.WithStrictVariables())
+ d := firstDiag(result.Diagnostics, "undefined-variable")
+ if d == nil {
+ t.Fatal("expected undefined-variable diagnostic")
+ }
+ if d.Range.Start.Line != 5 {
+ t.Errorf("Range.Start.Line=%d, want 5", d.Range.Start.Line)
+ }
+}
+
+// DR04 — multiple diagnostics on different lines each have their own Range.
+func TestRenderAudit_Diag_DR04_multiLineDiagnostics(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}\n{{ y }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{}, liquid.WithStrictVariables())
+ undef := allDiags(result.Diagnostics, "undefined-variable")
+ if len(undef) < 2 {
+ t.Fatalf("expected 2 undefined-variable diagnostics, got %d", len(undef))
+ }
+ if undef[0].Range.Start.Line == undef[1].Range.Start.Line {
+ t.Errorf("diagnostics on different lines should have different Range.Start.Line values: both got %d",
+ undef[0].Range.Start.Line)
+ }
+}
+
+// ============================================================================
+// Diagnostics — Cross-reference with Expression.Error (DE01–DE03)
+// ============================================================================
+
+// DE01 — when a variable causes a strict-mode error, Expression.Error is non-nil.
+func TestRenderAudit_Diag_DE01_expressionErrorNonNil(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ ghost }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceVariables: true},
+ liquid.WithStrictVariables(),
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("expected variable expression even when it errors")
+ }
+ if v.Error == nil {
+ t.Error("Expression.Error should be non-nil when the expression caused an error")
+ }
+}
+
+// DE02 — Expression.Error.Code matches the Diagnostic code.
+func TestRenderAudit_Diag_DE02_expressionErrorMatchesDiagCode(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ ghost }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceVariables: true},
+ liquid.WithStrictVariables(),
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ d := firstDiag(result.Diagnostics, "undefined-variable")
+ if v == nil || d == nil {
+ t.Skip("need both variable expression and diagnostic")
+ }
+ if v.Error == nil {
+ t.Fatal("Expression.Error is nil")
+ }
+ if v.Error.Code != d.Code {
+ t.Errorf("Expression.Error.Code=%q != Diagnostic.Code=%q", v.Error.Code, d.Code)
+ }
+}
+
+// DE03 — number of AuditError.Errors() equals number of diagnostics with severity error/warning.
+func TestRenderAudit_Diag_DE03_auditErrorCountMatchesDiagnostics(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}{{ y }}{{ z }}")
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if ae == nil {
+ t.Fatal("expected AuditError for 3 undefined variables")
+ }
+ if len(ae.Errors()) != 3 {
+ t.Errorf("AuditError.Errors() len=%d, want 3", len(ae.Errors()))
+ }
+ if len(result.Diagnostics) != 3 {
+ t.Errorf("Diagnostics len=%d, want 3", len(result.Diagnostics))
+ }
+}
+
+// ============================================================================
+// Diagnostics — Render Continues After Error (DC01–DC05)
+// ============================================================================
+
+// DC01 — partial output: content before and after the error is captured.
+func TestRenderAudit_Diag_DC01_partialOutput(t *testing.T) {
+ tpl := mustParseAudit(t, "before {{ 10 | divided_by: 0 }} after")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ if !strings.Contains(result.Output, "before") {
+ t.Errorf("Output=%q should contain 'before'", result.Output)
+ }
+ if !strings.Contains(result.Output, "after") {
+ t.Errorf("Output=%q should contain 'after' (render continued after error)", result.Output)
+ }
+}
+
+// DC02 — three variables: 1st OK, 2nd fails, 3rd OK → output contains 1st and 3rd values.
+func TestRenderAudit_Diag_DC02_continuesAfterMidError(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a }}{{ b | divided_by: 0 }}{{ c }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"a": "first", "b": 10, "c": "third"},
+ liquid.AuditOptions{},
+ )
+ if !strings.Contains(result.Output, "first") {
+ t.Errorf("Output=%q should contain 'first'", result.Output)
+ }
+ if !strings.Contains(result.Output, "third") {
+ t.Errorf("Output=%q should contain 'third' (render continued)", result.Output)
+ }
+}
+
+// DC03 — multiple filter errors in the same template: all are accumulated as Diagnostics.
+// Note: filter errors (divided_by: 0) produce Diagnostics but NOT an AuditError —
+// RenderAudit treats them as continuable non-fatal errors.
+func TestRenderAudit_Diag_DC03_multipleErrorsAccumulated(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 1 | divided_by: 0 }}{{ 2 | divided_by: 0 }}")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ // ae may be nil — divided_by:0 errors are captured as Diagnostics, not AuditErrors.
+ _ = ae
+ argErrs := allDiags(result.Diagnostics, "argument-error")
+ if len(argErrs) < 2 {
+ t.Errorf("argument-error diagnostic count=%d, want >= 2", len(argErrs))
+ }
+}
+
+// DC04 — AuditError.Error() contains a count summary.
+func TestRenderAudit_Diag_DC04_auditErrorMessage(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}{{ y }}")
+ _, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if ae == nil {
+ t.Fatal("expected AuditError")
+ }
+ msg := ae.Error()
+ if msg == "" {
+ t.Error("AuditError.Error() should not be empty")
+ }
+ // Should mention the number of errors.
+ if !strings.Contains(msg, "2") && !strings.Contains(msg, "error") {
+ t.Errorf("AuditError.Error()=%q should mention count or 'error'", msg)
+ }
+}
+
+// DC05 — AuditError.Errors() returns slice with SourceError types.
+func TestRenderAudit_Diag_DC05_auditErrorTypedErrors(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}")
+ _, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if ae == nil {
+ t.Fatal("expected AuditError")
+ }
+ errs := ae.Errors()
+ if len(errs) == 0 {
+ t.Fatal("AuditError.Errors() should not be empty")
+ }
+ // Each error should implement SourceError (which extends error).
+ for i, e := range errs {
+ if e == nil {
+ t.Errorf("Errors()[%d] is nil", i)
+ }
+ // SourceError interface has Error(), Cause(), Path(), LineNumber().
+ if e.Error() == "" {
+ t.Errorf("Errors()[%d].Error() is empty", i)
+ }
+ }
+}
+
+// ============================================================================
+// Diagnostics — not-iterable has valid Range span (already in existing tests,
+// but verifying message content too)
+// ============================================================================
+
+// D_notIterable_messageContent — message mentions the variable and type.
+func TestRenderAudit_Diag_notIterable_message(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in orders %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"orders": "shipped"},
+ liquid.AuditOptions{},
+ )
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic")
+ }
+ if d.Message == "" {
+ t.Error("Diagnostic.Message should not be empty")
+ }
+}
+
+// D_typeMismatch_messageContent — message mentions both types.
+func TestRenderAudit_Diag_typeMismatch_message(t *testing.T) {
+ tpl := mustParseAudit(t, `{% if status == 1 %}yes{% endif %}`)
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"status": "active"},
+ liquid.AuditOptions{},
+ )
+ d := firstDiag(result.Diagnostics, "type-mismatch")
+ if d == nil {
+ t.Fatal("expected type-mismatch diagnostic")
+ }
+ // Message should mention both the string and int types in some form.
+ if d.Message == "" {
+ t.Error("type-mismatch Message should not be empty")
+ }
+}
+
+// D_nilDereference_messageContent — message mentions the property.
+func TestRenderAudit_Diag_nilDereference_message(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ customer.address.city }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"customer": map[string]any{"address": nil}},
+ liquid.AuditOptions{},
+ )
+ d := firstDiag(result.Diagnostics, "nil-dereference")
+ if d == nil {
+ t.Fatal("expected nil-dereference diagnostic")
+ }
+ if d.Message == "" {
+ t.Error("nil-dereference Message should not be empty")
+ }
+ // Message should mention "city" (the property being accessed on nil).
+ if !strings.Contains(d.Message, "city") {
+ t.Errorf("Message=%q should mention 'city'", d.Message)
+ }
+}
diff --git a/render_audit_edge_test.go b/render_audit_edge_test.go
new file mode 100644
index 00000000..ea9e3890
--- /dev/null
+++ b/render_audit_edge_test.go
@@ -0,0 +1,546 @@
+package liquid_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// Position & Range Precision (P01–P08)
+// ============================================================================
+
+// P01 — expression on first line, first column: Start.Line=1, Start.Column=1.
+func TestRenderAudit_Position_P01_lineOneColOne(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Line != 1 {
+ t.Errorf("Range.Start.Line=%d, want 1", v.Range.Start.Line)
+ }
+ if v.Range.Start.Column != 1 {
+ t.Errorf("Range.Start.Column=%d, want 1", v.Range.Start.Column)
+ }
+}
+
+// P02 — expression on third line: Start.Line=3.
+func TestRenderAudit_Position_P02_lineThree(t *testing.T) {
+ tpl := mustParseAudit(t, "a\nb\n{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Line != 3 {
+ t.Errorf("Range.Start.Line=%d, want 3", v.Range.Start.Line)
+ }
+}
+
+// P03 — expression preceded by text: Start.Column > 1.
+func TestRenderAudit_Position_P03_columnOffset(t *testing.T) {
+ // "Hello " is 6 chars, so {{ x }} starts at col 7.
+ tpl := mustParseAudit(t, "Hello {{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Column <= 1 {
+ t.Errorf("Range.Start.Column=%d, want > 1 (preceded by text)", v.Range.Start.Column)
+ }
+}
+
+// P04 — End.Column = Start.Column + len(source) for single-line expression.
+func TestRenderAudit_Position_P04_endColumnPrecise(t *testing.T) {
+ // "{{ x }}" = 7 chars, at col 1 → End.Column = 8.
+ tpl := mustParseAudit(t, "{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ wantEnd := v.Range.Start.Column + len("{{ x }}")
+ if v.Range.End.Column != wantEnd {
+ t.Errorf("Range.End.Column=%d, want %d (Start+len)", v.Range.End.Column, wantEnd)
+ }
+}
+
+// P05 — two expressions in the same template have non-overlapping Ranges.
+func TestRenderAudit_Position_P05_noOverlap(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a }} {{ b }}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": 1, "b": 2}, liquid.AuditOptions{TraceVariables: true})
+ if len(result.Expressions) < 2 {
+ t.Fatalf("expected 2 expressions, got %d", len(result.Expressions))
+ }
+ r0, r1 := result.Expressions[0].Range, result.Expressions[1].Range
+ // r1.Start must be >= r0.End.
+ endBeforeStart := r1.Start.Line < r0.End.Line ||
+ (r1.Start.Line == r0.End.Line && r1.Start.Column < r0.End.Column)
+ if endBeforeStart {
+ t.Errorf("ranges overlap: r0=[%v→%v] r1=[%v→%v]", r0.Start, r0.End, r1.Start, r1.End)
+ }
+}
+
+// P06 — expression on last line of multiline template: Line is correct.
+func TestRenderAudit_Position_P07_lastLine(t *testing.T) {
+ tpl := mustParseAudit(t, "line1\nline2\nline3\nline4\n{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Line != 5 {
+ t.Errorf("Range.Start.Line=%d, want 5 (last line)", v.Range.Start.Line)
+ }
+}
+
+// P07 — assign tag: Start.Column=1 when at start of line.
+func TestRenderAudit_Position_P08_assignStartColumn(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "y" %}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceAssignments: true})
+ a := firstExpr(result.Expressions, liquid.KindAssignment)
+ if a == nil {
+ t.Fatal("no assignment expression")
+ }
+ if a.Range.Start.Column != 1 {
+ t.Errorf("Range.Start.Column=%d, want 1 (assign at start of line)", a.Range.Start.Column)
+ }
+}
+
+// ============================================================================
+// Edge Cases (E01–E15)
+// ============================================================================
+
+// E01 — empty template: no crash, empty output, no expressions, no diagnostics.
+func TestRenderAudit_Edge_E01_emptyTemplate(t *testing.T) {
+ tpl := mustParseAudit(t, "")
+ result := auditOK(t, tpl, liquid.Bindings{},
+ liquid.AuditOptions{
+ TraceVariables: true,
+ TraceConditions: true,
+ TraceIterations: true,
+ TraceAssignments: true,
+ },
+ )
+ if result.Output != "" {
+ t.Errorf("Output=%q, want empty", result.Output)
+ }
+ if len(result.Expressions) != 0 {
+ t.Errorf("Expressions=%d, want 0", len(result.Expressions))
+ }
+ if len(result.Diagnostics) != 0 {
+ t.Errorf("Diagnostics=%d, want 0", len(result.Diagnostics))
+ }
+}
+
+// E02 — template with only text: no traces.
+func TestRenderAudit_Edge_E02_textOnly(t *testing.T) {
+ tpl := mustParseAudit(t, "Hello, World!")
+ result := auditOK(t, tpl, liquid.Bindings{},
+ liquid.AuditOptions{TraceVariables: true, TraceConditions: true},
+ )
+ assertOutput(t, result, "Hello, World!")
+ if len(result.Expressions) != 0 {
+ t.Errorf("Expressions=%d, want 0 for text-only template", len(result.Expressions))
+ }
+}
+
+// E03 — deeply nested for×if×if: Depth increments correctly.
+func TestRenderAudit_Edge_E03_tripleNesting(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for i in items %}{% if true %}{% if true %}{{ i }}{% endif %}{% endif %}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression in deeply nested template")
+ }
+ if v.Depth != 3 {
+ t.Errorf("Depth=%d, want 3 (for > if > if)", v.Depth)
+ }
+}
+
+// E04 — {% comment %} content is not traced.
+func TestRenderAudit_Edge_E04_commentNotTraced(t *testing.T) {
+ tpl := mustParseAudit(t, "{% comment %}{{ secret }}{% endcomment %}{{ visible }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"secret": "hidden", "visible": "shown"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ for _, e := range result.Expressions {
+ if e.Kind == liquid.KindVariable && e.Variable != nil && e.Variable.Name == "secret" {
+ t.Error("secret inside comment should not be traced")
+ }
+ }
+ assertOutput(t, result, "shown")
+}
+
+// E05 — {% raw %} content is not parsed or traced.
+func TestRenderAudit_Edge_E05_rawNotTraced(t *testing.T) {
+ tpl := mustParseAudit(t, "{% raw %}{{ not_parsed }}{% endraw %}")
+ result := auditOK(t, tpl, liquid.Bindings{},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ assertOutput(t, result, "{{ not_parsed }}")
+ if len(result.Expressions) != 0 {
+ t.Errorf("Expressions=%d, want 0 inside raw block", len(result.Expressions))
+ }
+}
+
+// E06 — Unicode values are preserved correctly in traces.
+func TestRenderAudit_Edge_E06_unicodeValues(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ greeting }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"greeting": "Olá, João! 🎉"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "Olá, João! 🎉" {
+ t.Errorf("Value=%v, want unicode greeting", v.Variable.Value)
+ }
+ assertOutput(t, result, "Olá, João! 🎉")
+}
+
+// E07 — whitespace control tags ({%- -%}): output is trimmed, traces are still present.
+func TestRenderAudit_Edge_E07_whitespaceControl(t *testing.T) {
+ tpl := mustParseAudit(t, " {%- if true -%} yes {%- endif -%} ")
+ result := auditOK(t, tpl, liquid.Bindings{},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ // Output should be trimmed around the tags.
+ if strings.Contains(result.Output, " yes ") {
+ t.Logf("Output=%q (whitespace might be trimmed)", result.Output)
+ }
+ // But traces should still appear.
+ c := firstExpr(result.Expressions, liquid.KindCondition)
+ if c == nil {
+ t.Error("condition expression should still be traced with whitespace control tags")
+ }
+}
+
+// E08 — increment tag: no crash.
+func TestRenderAudit_Edge_E08_incrementTag(t *testing.T) {
+ tpl := mustParseAudit(t, "{% increment counter %}{% increment counter %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+}
+
+// E09 — decrement tag: no crash.
+func TestRenderAudit_Edge_E09_decrementTag(t *testing.T) {
+ tpl := mustParseAudit(t, "{% decrement counter %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+}
+
+// E10 — cycle tag: no crash.
+func TestRenderAudit_Edge_E10_cycleTag(t *testing.T) {
+ tpl := mustParseAudit(t, `{% for i in items %}{% cycle "odd", "even" %}{% endfor %}`)
+ result := auditOK(t, tpl, liquid.Bindings{"items": []int{1, 2, 3}}, liquid.AuditOptions{})
+ if result.Output != "oddevenodd" {
+ t.Errorf("Output=%q, want oddevenodd", result.Output)
+ }
+}
+
+// E11 — very long filter pipeline (5 steps): no crash, traces correctly.
+func TestRenderAudit_Edge_E11_longPipeline(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ name | downcase | upcase | downcase | upcase | downcase }}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"name": "Hello"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 5 {
+ t.Errorf("Pipeline len=%d, want 5", len(v.Variable.Pipeline))
+ }
+}
+
+// E12 — multiple independent if blocks: each produces its own ConditionTrace.
+func TestRenderAudit_Edge_E12_multipleConditionBlocks(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if a %}yes{% endif %}{% if b %}no{% endif %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"a": true, "b": false},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ conds := allExprs(result.Expressions, liquid.KindCondition)
+ if len(conds) != 2 {
+ t.Errorf("condition expression count=%d, want 2", len(conds))
+ }
+}
+
+// E13 — assign then for then if: assignment comes first; all three kinds are present.
+func TestRenderAudit_Edge_E13_executionOrder(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = 1 %}{% for i in items %}{% if i > x %}big{% endif %}{% endfor %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{0, 2}},
+ liquid.AuditOptions{
+ TraceAssignments: true,
+ TraceIterations: true,
+ TraceConditions: true,
+ },
+ )
+ if len(result.Expressions) == 0 {
+ t.Fatal("no expressions")
+ }
+ // The assignment tag appears before the for loop, so it is always first.
+ if result.Expressions[0].Kind != liquid.KindAssignment {
+ t.Errorf("Expressions[0].Kind=%q, want assignment (comes before for)", result.Expressions[0].Kind)
+ }
+ // All three expression kinds must be present somewhere.
+ kinds := make(map[liquid.ExpressionKind]bool)
+ for _, e := range result.Expressions {
+ kinds[e.Kind] = true
+ }
+ if !kinds[liquid.KindIteration] {
+ t.Error("expected at least one iteration expression")
+ }
+ if !kinds[liquid.KindCondition] {
+ t.Error("expected at least one condition expression")
+ }
+}
+
+// E14 — AuditResult.result nil pointer never happens even for panicky templates.
+func TestRenderAudit_Edge_E14_resultNeverNil(t *testing.T) {
+ templates := []string{
+ "",
+ "{{ x }}",
+ "{% if true %}{% endif %}",
+ "{% for i in items %}{% endfor %}",
+ }
+ for _, src := range templates {
+ tpl := mustParseAudit(t, src)
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Errorf("RenderAudit(%q) returned nil result", src)
+ }
+ }
+}
+
+// E15 — echo tag works like variable output.
+func TestRenderAudit_Edge_E15_echoTag(t *testing.T) {
+ tpl := mustParseAudit(t, "{% echo name %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"name": "Alice"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ assertOutput(t, result, "Alice")
+}
+
+// ============================================================================
+// End-to-End Spec Example (S01–S02)
+// ============================================================================
+
+// S01 — the complete spec example template produces the expected output
+// and the Expressions array contains all expression kinds in order.
+func TestRenderAudit_E2E_S01_specExample(t *testing.T) {
+ src := `{% assign title = page.title | upcase %}
+{{ title }}
+
+{% if customer.age >= 18 %}
+ Welcome, {{ customer.name }}!
+{% else %}
+ Restricted.
+{% endif %}
+
+{% for item in cart.items %}
+ {{ item.name }} — ${{ item.price | times: 1.1 | round }}
+{% endfor %}`
+
+ bindings := liquid.Bindings{
+ "page": map[string]any{"title": "my store"},
+ "customer": map[string]any{"name": "Alice", "age": 25},
+ "cart": map[string]any{
+ "items": []map[string]any{
+ {"name": "Shirt", "price": 50},
+ {"name": "Pants", "price": 120},
+ },
+ },
+ }
+
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(bindings, liquid.AuditOptions{
+ TraceVariables: true,
+ TraceConditions: true,
+ TraceIterations: true,
+ TraceAssignments: true,
+ MaxIterationTraceItems: 100,
+ })
+ if ae != nil {
+ t.Fatalf("unexpected AuditError: %v", ae)
+ }
+
+ // ---- Output assertions ------------------------------------------------
+ if !strings.Contains(result.Output, "MY STORE") {
+ t.Errorf("Output should contain 'MY STORE' (assign title | upcase), got: %q", result.Output)
+ }
+ if !strings.Contains(result.Output, "Welcome, Alice!") {
+ t.Errorf("Output should contain 'Welcome, Alice!', got: %q", result.Output)
+ }
+ if strings.Contains(result.Output, "Restricted.") {
+ t.Error("Output should NOT contain 'Restricted.' (customer.age=25 is adult)")
+ }
+ if !strings.Contains(result.Output, "Shirt") {
+ t.Errorf("Output should contain 'Shirt', got: %q", result.Output)
+ }
+ if !strings.Contains(result.Output, "Pants") {
+ t.Errorf("Output should contain 'Pants', got: %q", result.Output)
+ }
+
+ // ---- Expression kind assertions ----------------------------------------
+ kinds := make(map[liquid.ExpressionKind]int)
+ for _, e := range result.Expressions {
+ kinds[e.Kind]++
+ }
+
+ if kinds[liquid.KindAssignment] < 1 {
+ t.Error("expected at least 1 assignment expression ({% assign title %})")
+ }
+ if kinds[liquid.KindVariable] < 1 {
+ t.Error("expected variable expressions")
+ }
+ if kinds[liquid.KindCondition] < 1 {
+ t.Error("expected at least 1 condition expression ({% if customer.age >= 18 %})")
+ }
+ if kinds[liquid.KindIteration] < 1 {
+ t.Error("expected at least 1 iteration expression ({% for item in cart.items %})")
+ }
+
+ // ---- Assignment trace --------------------------------------------------
+ assigns := allExprs(result.Expressions, liquid.KindAssignment)
+ if len(assigns) == 0 || assigns[0].Assignment == nil {
+ t.Fatal("no assignment trace for 'assign title'")
+ }
+ if assigns[0].Assignment.Variable != "title" {
+ t.Errorf("assign.Variable=%q, want title", assigns[0].Assignment.Variable)
+ }
+ if assigns[0].Assignment.Value != "MY STORE" {
+ t.Errorf("assign.Value=%v, want MY STORE", assigns[0].Assignment.Value)
+ }
+ if len(assigns[0].Assignment.Pipeline) != 1 || assigns[0].Assignment.Pipeline[0].Filter != "upcase" {
+ t.Error("assign pipeline should have one step: upcase")
+ }
+
+ // ---- Condition trace ---------------------------------------------------
+ conds := allExprs(result.Expressions, liquid.KindCondition)
+ if len(conds) == 0 || conds[0].Condition == nil {
+ t.Fatal("no condition trace")
+ }
+ // The if branch (customer.age >= 18) should be executed.
+ found := false
+ for _, b := range conds[0].Condition.Branches {
+ if b.Kind == "if" && b.Executed {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("if branch (customer.age >= 18) should be Executed=true")
+ }
+
+ // ---- Iteration trace ---------------------------------------------------
+ iters := allExprs(result.Expressions, liquid.KindIteration)
+ if len(iters) == 0 || iters[0].Iteration == nil {
+ t.Fatal("no iteration trace")
+ }
+ if iters[0].Iteration.Variable != "item" {
+ t.Errorf("iter.Variable=%q, want item", iters[0].Iteration.Variable)
+ }
+ if iters[0].Iteration.Length != 2 {
+ t.Errorf("iter.Length=%d, want 2 (two cart items)", iters[0].Iteration.Length)
+ }
+ if iters[0].Iteration.TracedCount != 2 {
+ t.Errorf("iter.TracedCount=%d, want 2", iters[0].Iteration.TracedCount)
+ }
+
+ // ---- No diagnostics ----------------------------------------------------
+ if len(result.Diagnostics) > 0 {
+ t.Errorf("expected no diagnostics, got %v", result.Diagnostics)
+ }
+}
+
+// S02 — verify that the spec example can also be validated without panic.
+func TestRenderAudit_E2E_S02_validateSpecExample(t *testing.T) {
+ src := `{% assign title = page.title | upcase %}
+{{ title }}
+{% if customer.age >= 18 %}
+ Welcome, {{ customer.name }}!
+{% else %}
+ Restricted.
+{% endif %}
+{% for item in cart.items %}
+ {{ item.name }}
+{% endfor %}`
+
+ tpl := mustParseAudit(t, src)
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatalf("Validate returned error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("Validate result must not be nil")
+ }
+ // The spec example is well-formed and non-empty — no empty-block diagnostics expected.
+ emptyBlocks := allDiags(result.Diagnostics, "empty-block")
+ if len(emptyBlocks) > 0 {
+ t.Errorf("unexpected empty-block diagnostics in spec example: %v", emptyBlocks)
+ }
+}
+
+// ============================================================================
+// Additional RenderAudit parity test: matches Template.Render exactly.
+// ============================================================================
+
+func TestRenderAudit_Parity_withRender(t *testing.T) {
+ templates := []struct {
+ name string
+ src string
+ bindings liquid.Bindings
+ }{
+ {"simple", "Hello, {{ name }}!", liquid.Bindings{"name": "World"}},
+ {"if_true", "{% if x %}yes{% else %}no{% endif %}", liquid.Bindings{"x": true}},
+ {"if_false", "{% if x %}yes{% else %}no{% endif %}", liquid.Bindings{"x": false}},
+ {"for", "{% for i in items %}{{ i }}{% endfor %}", liquid.Bindings{"items": []int{1, 2, 3}}},
+ {"assign", `{% assign x = "hi" %}{{ x }}`, liquid.Bindings{}},
+ {"filters", "{{ name | upcase | truncate: 3 }}", liquid.Bindings{"name": "hello"}},
+ }
+
+ for _, tt := range templates {
+ t.Run(tt.name, func(t *testing.T) {
+ eng := newAuditEngine()
+ expected, se := eng.ParseAndRenderString(tt.src, tt.bindings)
+ if se != nil {
+ t.Fatalf("baseline Render error: %v", se)
+ }
+ tpl := mustParseAuditWith(t, eng, tt.src)
+ result := auditOK(t, tpl, tt.bindings,
+ liquid.AuditOptions{
+ TraceVariables: true,
+ TraceConditions: true,
+ TraceIterations: true,
+ TraceAssignments: true,
+ },
+ )
+ if result.Output != expected {
+ t.Errorf("RenderAudit output=%q, Render output=%q (must be identical)", result.Output, expected)
+ }
+ })
+ }
+}
diff --git a/render_audit_helpers_test.go b/render_audit_helpers_test.go
new file mode 100644
index 00000000..69835c10
--- /dev/null
+++ b/render_audit_helpers_test.go
@@ -0,0 +1,185 @@
+package liquid_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// --------------------------------------------------------------------------
+// Parse / render helpers
+// --------------------------------------------------------------------------
+
+// mustParseAudit parses a template string, failing the test on any error.
+func mustParseAudit(t *testing.T, src string) *liquid.Template {
+ t.Helper()
+ tpl, err := newAuditEngine().ParseString(src)
+ if err != nil {
+ t.Fatalf("ParseString(%q): %v", src, err)
+ }
+ return tpl
+}
+
+// mustParseAuditWith is like mustParseAudit but uses a caller-provided engine.
+func mustParseAuditWith(t *testing.T, eng *liquid.Engine, src string) *liquid.Template {
+ t.Helper()
+ tpl, err := eng.ParseString(src)
+ if err != nil {
+ t.Fatalf("ParseString(%q): %v", src, err)
+ }
+ return tpl
+}
+
+// auditOK renders with audit and asserts no AuditError is returned.
+func auditOK(t *testing.T, tpl *liquid.Template, vars liquid.Bindings, opts liquid.AuditOptions, renderOpts ...liquid.RenderOption) *liquid.AuditResult {
+ t.Helper()
+ result, ae := tpl.RenderAudit(vars, opts, renderOpts...)
+ if result == nil {
+ t.Fatal("RenderAudit returned nil result")
+ }
+ if ae != nil {
+ t.Fatalf("RenderAudit returned unexpected AuditError: %v", ae)
+ }
+ return result
+}
+
+// auditErr renders with audit and asserts that an AuditError is returned.
+func auditErr(t *testing.T, tpl *liquid.Template, vars liquid.Bindings, opts liquid.AuditOptions, renderOpts ...liquid.RenderOption) (*liquid.AuditResult, *liquid.AuditError) {
+ t.Helper()
+ result, ae := tpl.RenderAudit(vars, opts, renderOpts...)
+ if result == nil {
+ t.Fatal("RenderAudit returned nil result (must be non-nil even on error)")
+ }
+ if ae == nil {
+ t.Fatal("RenderAudit: expected AuditError but got nil")
+ }
+ return result, ae
+}
+
+// --------------------------------------------------------------------------
+// Expression finders
+// --------------------------------------------------------------------------
+
+// firstExpr returns the first Expression with matching Kind, or nil.
+func firstExpr(exprs []liquid.Expression, kind liquid.ExpressionKind) *liquid.Expression {
+ for i := range exprs {
+ if exprs[i].Kind == kind {
+ return &exprs[i]
+ }
+ }
+ return nil
+}
+
+// allExprs returns all Expressions with matching Kind.
+func allExprs(exprs []liquid.Expression, kind liquid.ExpressionKind) []liquid.Expression {
+ var out []liquid.Expression
+ for _, e := range exprs {
+ if e.Kind == kind {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+// nthExpr returns the n-th (0-based) Expression with matching Kind, or nil.
+func nthExpr(exprs []liquid.Expression, kind liquid.ExpressionKind, n int) *liquid.Expression {
+ idx := 0
+ for i := range exprs {
+ if exprs[i].Kind == kind {
+ if idx == n {
+ return &exprs[i]
+ }
+ idx++
+ }
+ }
+ return nil
+}
+
+// --------------------------------------------------------------------------
+// Diagnostic finder
+// --------------------------------------------------------------------------
+
+// firstDiag returns the first Diagnostic with matching Code, or nil.
+func firstDiag(diags []liquid.Diagnostic, code string) *liquid.Diagnostic {
+ for i := range diags {
+ if diags[i].Code == code {
+ return &diags[i]
+ }
+ }
+ return nil
+}
+
+// allDiags returns all Diagnostics with matching Code.
+func allDiags(diags []liquid.Diagnostic, code string) []liquid.Diagnostic {
+ var out []liquid.Diagnostic
+ for _, d := range diags {
+ if d.Code == code {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// --------------------------------------------------------------------------
+// Assertion helpers
+// --------------------------------------------------------------------------
+
+// assertOutput checks result.Output equals want.
+func assertOutput(t *testing.T, result *liquid.AuditResult, want string) {
+ t.Helper()
+ if result.Output != want {
+ t.Errorf("Output=%q, want %q", result.Output, want)
+ }
+}
+
+// assertExprCount checks the total number of Expressions.
+func assertExprCount(t *testing.T, result *liquid.AuditResult, want int) {
+ t.Helper()
+ if len(result.Expressions) != want {
+ t.Errorf("len(Expressions)=%d, want %d", len(result.Expressions), want)
+ }
+}
+
+// assertNoDiags asserts the result has no Diagnostics.
+func assertNoDiags(t *testing.T, result *liquid.AuditResult) {
+ t.Helper()
+ if len(result.Diagnostics) > 0 {
+ t.Errorf("expected no diagnostics, got %d: %v", len(result.Diagnostics), result.Diagnostics)
+ }
+}
+
+// assertRangeValid checks that Range.Start.Line >= 1.
+func assertRangeValid(t *testing.T, r liquid.Range, label string) {
+ t.Helper()
+ if r.Start.Line < 1 {
+ t.Errorf("%s: Range.Start.Line=%d, want >= 1", label, r.Start.Line)
+ }
+ if r.Start.Column < 1 {
+ t.Errorf("%s: Range.Start.Column=%d, want >= 1", label, r.Start.Column)
+ }
+}
+
+// assertRangeSpan checks that Range.End > Range.Start (valid non-zero span).
+func assertRangeSpan(t *testing.T, r liquid.Range, label string) {
+ t.Helper()
+ assertRangeValid(t, r, label)
+ endIsAfter := r.End.Line > r.Start.Line ||
+ (r.End.Line == r.Start.Line && r.End.Column > r.Start.Column)
+ if !endIsAfter {
+ t.Errorf("%s: Range.End (%v) is not after Range.Start (%v)", label, r.End, r.Start)
+ }
+}
+
+// --------------------------------------------------------------------------
+// Numeric formatting helpers
+// --------------------------------------------------------------------------
+
+// sprintVal converts any value to its default string representation.
+// Useful for comparing numeric values without caring about int vs float64.
+func sprintVal(v any) string {
+ return fmt.Sprintf("%v", v)
+}
+
+// iptr returns a pointer to the given int value.
+func iptr(i int) *int { return &i }
diff --git a/render_audit_iteration_test.go b/render_audit_iteration_test.go
new file mode 100644
index 00000000..a2b32ed5
--- /dev/null
+++ b/render_audit_iteration_test.go
@@ -0,0 +1,742 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// IterationTrace — Basic Attributes (I01–I07)
+// ============================================================================
+
+// I01 — basic for loop: Variable and Collection names.
+func TestRenderAudit_Iteration_I01_basic(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b"}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Variable != "item" {
+ t.Errorf("Variable=%q, want item", it.Iteration.Variable)
+ }
+ if it.Iteration.Collection != "items" {
+ t.Errorf("Collection=%q, want items", it.Iteration.Collection)
+ }
+}
+
+// I02 — iteration over empty collection: Length=0, TracedCount=0.
+func TestRenderAudit_Iteration_I02_emptyCollection(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{"items": []string{}}, liquid.AuditOptions{TraceIterations: true})
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Length != 0 {
+ t.Errorf("Length=%d, want 0", it.Iteration.Length)
+ }
+ if it.Iteration.TracedCount != 0 {
+ t.Errorf("TracedCount=%d, want 0", it.Iteration.TracedCount)
+ }
+ if it.Iteration.Truncated {
+ t.Error("Truncated should be false for empty collection")
+ }
+}
+
+// I03 — single-item collection: Length=1, TracedCount=1.
+func TestRenderAudit_Iteration_I03_singleItem(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{"items": []string{"only"}}, liquid.AuditOptions{TraceIterations: true})
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Length != 1 {
+ t.Errorf("Length=%d, want 1", it.Iteration.Length)
+ }
+ if it.Iteration.TracedCount != 1 {
+ t.Errorf("TracedCount=%d, want 1", it.Iteration.TracedCount)
+ }
+}
+
+// I04 — 100 items: Length=100.
+func TestRenderAudit_Iteration_I04_manyItems(t *testing.T) {
+ items := make([]int, 100)
+ for i := range items {
+ items[i] = i
+ }
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{"items": items}, liquid.AuditOptions{TraceIterations: true})
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Length != 100 {
+ t.Errorf("Length=%d, want 100", it.Iteration.Length)
+ }
+}
+
+// I05 — iteration over a map/hash: Length is the number of key-value pairs.
+func TestRenderAudit_Iteration_I05_overMap(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for pair in hash %}{{ pair[0] }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"hash": map[string]any{"a": 1, "b": 2}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Length != 2 {
+ t.Errorf("Length=%d, want 2 (hash with 2 entries)", it.Iteration.Length)
+ }
+}
+
+// I06 — range literal (1..5): Length=5.
+func TestRenderAudit_Iteration_I06_rangeLiteral(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for i in (1..5) %}{{ i }}{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceIterations: true})
+ assertOutput(t, result, "12345")
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Variable != "i" {
+ t.Errorf("Variable=%q, want i", it.Iteration.Variable)
+ }
+ if it.Iteration.Length != 5 {
+ t.Errorf("Length=%d, want 5", it.Iteration.Length)
+ }
+}
+
+// I07 — reversed range (5..1): this implementation yields a non-positive Length
+// (computed as end-start+1 = 1-5+1 = -3) meaning no elements are iterated.
+func TestRenderAudit_Iteration_I07_emptyRange(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for i in (5..1) %}{{ i }}{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceIterations: true})
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ // For a reversed/empty range, Length is non-positive (no iterations executed).
+ if it.Iteration.Length > 0 {
+ t.Errorf("Length=%d, want <= 0 for reversed range (5..1)", it.Iteration.Length)
+ }
+ if it.Iteration.TracedCount != 0 {
+ t.Errorf("TracedCount=%d, want 0 (no iterations for reversed range)", it.Iteration.TracedCount)
+ }
+}
+
+// ============================================================================
+// IterationTrace — Limit, Offset, Reversed (IL01–IL07)
+// ============================================================================
+
+// IL01 — limit:3 with 5 items: Limit=ptr(3), Length=3 (actual iterations run), TracedCount=3.
+// Note: Length reflects the number of elements actually iterated (post-limit), not the
+// original collection size.
+func TestRenderAudit_Iteration_IL01_limit(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items limit:3 %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3, 4, 5}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "123")
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Limit == nil {
+ t.Fatal("Limit should be non-nil when limit: is specified")
+ }
+ if *it.Iteration.Limit != 3 {
+ t.Errorf("*Limit=%d, want 3", *it.Iteration.Limit)
+ }
+ if it.Iteration.Length != 3 {
+ t.Errorf("Length=%d, want 3 (post-limit iteration count)", it.Iteration.Length)
+ }
+ if it.Iteration.TracedCount != 3 {
+ t.Errorf("TracedCount=%d, want 3", it.Iteration.TracedCount)
+ }
+}
+
+// IL02 — offset:2 with 5 items: Offset=ptr(2), Length=3 (items remaining after skip), TracedCount=3.
+// Note: Length reflects elements actually iterated (collection size minus offset), not total.
+func TestRenderAudit_Iteration_IL02_offset(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items offset:2 %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{10, 20, 30, 40, 50}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "304050")
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Offset == nil {
+ t.Fatal("Offset should be non-nil when offset: is specified")
+ }
+ if *it.Iteration.Offset != 2 {
+ t.Errorf("*Offset=%d, want 2", *it.Iteration.Offset)
+ }
+ if it.Iteration.Length != 3 {
+ t.Errorf("Length=%d, want 3 (5 items minus 2 offset)", it.Iteration.Length)
+ }
+}
+
+// IL03 — limit:2 offset:1 combined.
+func TestRenderAudit_Iteration_IL03_limitAndOffset(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items limit:2 offset:1 %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{10, 20, 30, 40}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "2030")
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Limit == nil {
+ t.Error("Limit should be non-nil")
+ } else if *it.Iteration.Limit != 2 {
+ t.Errorf("*Limit=%d, want 2", *it.Iteration.Limit)
+ }
+ if it.Iteration.Offset == nil {
+ t.Error("Offset should be non-nil")
+ } else if *it.Iteration.Offset != 1 {
+ t.Errorf("*Offset=%d, want 1", *it.Iteration.Offset)
+ }
+}
+
+// IL04 — reversed: Reversed=true.
+func TestRenderAudit_Iteration_IL04_reversed(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items reversed %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "321")
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if !it.Iteration.Reversed {
+ t.Error("Reversed should be true when `reversed` modifier is used")
+ }
+}
+
+// IL05 — no modifiers: Limit=nil, Offset=nil, Reversed=false.
+func TestRenderAudit_Iteration_IL05_noModifiers(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Limit != nil {
+ t.Errorf("Limit should be nil (not specified), got %d", *it.Iteration.Limit)
+ }
+ if it.Iteration.Offset != nil {
+ t.Errorf("Offset should be nil (not specified), got %d", *it.Iteration.Offset)
+ }
+ if it.Iteration.Reversed {
+ t.Error("Reversed should be false when not specified")
+ }
+}
+
+// IL06 — limit:0: iterates zero times despite non-empty collection.
+func TestRenderAudit_Iteration_IL06_limitZero(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items limit:0 %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "")
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Limit == nil {
+ t.Error("Limit should be non-nil for limit:0")
+ } else if *it.Iteration.Limit != 0 {
+ t.Errorf("*Limit=%d, want 0", *it.Iteration.Limit)
+ }
+ if it.Iteration.TracedCount != 0 {
+ t.Errorf("TracedCount=%d, want 0 (no iterations)", it.Iteration.TracedCount)
+ }
+}
+
+// ============================================================================
+// IterationTrace — MaxIterationTraceItems / Truncation (IT01–IT07)
+// ============================================================================
+
+// IT01 — MaxIterItems=0 (unlimited) with 10 items: Truncated=false, TracedCount=10.
+func TestRenderAudit_Iteration_IT01_noLimit(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": items},
+ liquid.AuditOptions{TraceIterations: true, MaxIterationTraceItems: 0},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Truncated {
+ t.Error("Truncated should be false when MaxIterationTraceItems=0 (unlimited)")
+ }
+ if it.Iteration.TracedCount != 10 {
+ t.Errorf("TracedCount=%d, want 10", it.Iteration.TracedCount)
+ }
+}
+
+// IT02 — MaxIterItems=5 with 10 items: Truncated=true, TracedCount=5.
+func TestRenderAudit_Iteration_IT02_truncation(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": items},
+ liquid.AuditOptions{TraceIterations: true, MaxIterationTraceItems: 5},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if !it.Iteration.Truncated {
+ t.Error("Truncated should be true when MaxIterationTraceItems=5 and 10 items")
+ }
+ if it.Iteration.TracedCount != 5 {
+ t.Errorf("TracedCount=%d, want 5", it.Iteration.TracedCount)
+ }
+}
+
+// IT03 — MaxIterItems=10 with only 5 items: Truncated=false, TracedCount=5.
+func TestRenderAudit_Iteration_IT03_limitExceedsItems(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3, 4, 5}},
+ liquid.AuditOptions{TraceIterations: true, MaxIterationTraceItems: 10},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Truncated {
+ t.Error("Truncated should be false (only 5 items, limit 10)")
+ }
+ if it.Iteration.TracedCount != 5 {
+ t.Errorf("TracedCount=%d, want 5", it.Iteration.TracedCount)
+ }
+}
+
+// IT04 — MaxIterItems=1 with 100 items: Truncated=true, TracedCount=1.
+func TestRenderAudit_Iteration_IT04_limitOne(t *testing.T) {
+ items := make([]int, 100)
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": items},
+ liquid.AuditOptions{TraceIterations: true, MaxIterationTraceItems: 1},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if !it.Iteration.Truncated {
+ t.Error("Truncated should be true")
+ }
+ if it.Iteration.TracedCount != 1 {
+ t.Errorf("TracedCount=%d, want 1", it.Iteration.TracedCount)
+ }
+}
+
+// IT05 — MaxIterItems limits inner expression tracing but NOT the render output.
+func TestRenderAudit_Iteration_IT05_outputCompleteEvenWhenTruncated(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3, 4, 5}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true, MaxIterationTraceItems: 2},
+ )
+ // Output must be complete despite truncation.
+ assertOutput(t, result, "12345")
+
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if !it.Iteration.Truncated {
+ t.Error("Truncated should be true (5 items, limit 2)")
+ }
+
+ // Variable expressions are only traced for the first 2 iterations.
+ varExprs := allExprs(result.Expressions, liquid.KindVariable)
+ if len(varExprs) != 2 {
+ t.Errorf("variable expression count=%d, want 2 (only traced iterations)", len(varExprs))
+ }
+}
+
+// IT06 — nested for loops each have their own TracedCount.
+// Note: inner for IterationTraces are emitted BEFORE the outer for's IterationTrace
+// (because the outer body executes before the outer event finishes).
+func TestRenderAudit_Iteration_IT06_nestedForSeparateTracedCount(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for outer in outers %}{% for inner in inners %}x{% endfor %}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{
+ "outers": []int{1, 2},
+ "inners": []int{1, 2, 3},
+ },
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ iterExprs := allExprs(result.Expressions, liquid.KindIteration)
+ // Expect 3 iteration expressions: inner for × 2 outer iterations + outer for × 1.
+ if len(iterExprs) < 2 {
+ t.Fatalf("expected >= 2 iteration expressions, got %d", len(iterExprs))
+ }
+ // Find the outer for by variable name. The outer for's trace is the LAST one emitted.
+ var outerIter *liquid.Expression
+ for i := range iterExprs {
+ if iterExprs[i].Iteration != nil && iterExprs[i].Iteration.Variable == "outer" {
+ e := iterExprs[i]
+ outerIter = &e
+ break
+ }
+ }
+ if outerIter == nil {
+ t.Fatal("no iteration trace with variable=\"outer\"")
+ }
+ if outerIter.Iteration.Length != 2 {
+ t.Errorf("outer.Length=%d, want 2", outerIter.Iteration.Length)
+ }
+ // Verify at least one inner-for trace with variable="inner".
+ var innerIter *liquid.Expression
+ for i := range iterExprs {
+ if iterExprs[i].Iteration != nil && iterExprs[i].Iteration.Variable == "inner" {
+ e := iterExprs[i]
+ innerIter = &e
+ break
+ }
+ }
+ if innerIter == nil {
+ t.Fatal("no iteration trace with variable=\"inner\"")
+ }
+ if innerIter.Iteration.Length != 3 {
+ t.Errorf("inner.Length=%d, want 3", innerIter.Iteration.Length)
+ }
+}
+
+// IT07 — MaxIterItems with empty collection: Truncated=false, TracedCount=0.
+func TestRenderAudit_Iteration_IT07_maxIterWithEmptyCollection(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{}},
+ liquid.AuditOptions{TraceIterations: true, MaxIterationTraceItems: 3},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Truncated {
+ t.Error("Truncated should be false for empty collection")
+ }
+ if it.Iteration.TracedCount != 0 {
+ t.Errorf("TracedCount=%d, want 0", it.Iteration.TracedCount)
+ }
+}
+
+// ============================================================================
+// IterationTrace — Inner Expressions appear per iteration (IF01–IF06)
+// ============================================================================
+
+// IF01 — variable inside for appears once per iteration.
+func TestRenderAudit_Iteration_IF01_variablePerIteration(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true},
+ )
+ varExprs := allExprs(result.Expressions, liquid.KindVariable)
+ if len(varExprs) != 3 {
+ t.Errorf("variable expressions=%d, want 3 (one per iteration)", len(varExprs))
+ }
+ for i, v := range varExprs {
+ if v.Variable == nil {
+ continue
+ }
+ expected := []string{"a", "b", "c"}[i]
+ if v.Variable.Value != expected {
+ t.Errorf("varExprs[%d].Value=%v, want %q", i, v.Variable.Value, expected)
+ }
+ }
+}
+
+// IF02 — condition inside for appears once per iteration.
+func TestRenderAudit_Iteration_IF02_conditionPerIteration(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{% if item > 2 %}big{% endif %}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true, TraceConditions: true},
+ )
+ condExprs := allExprs(result.Expressions, liquid.KindCondition)
+ if len(condExprs) != 3 {
+ t.Errorf("condition expressions=%d, want 3 (one per iteration)", len(condExprs))
+ }
+}
+
+// IF03 — assign inside for appears once per iteration.
+func TestRenderAudit_Iteration_IF03_assignPerIteration(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{% assign doubled = item | times: 2 %}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true, TraceAssignments: true},
+ )
+ assignExprs := allExprs(result.Expressions, liquid.KindAssignment)
+ if len(assignExprs) != 3 {
+ t.Errorf("assignment expressions=%d, want 3 (one per iteration)", len(assignExprs))
+ }
+}
+
+// IF04 — nested for: inner expressions have Depth=2.
+func TestRenderAudit_Iteration_IF04_nestedForDepth(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for outer in outers %}{% for inner in inners %}{{ inner }}{% endfor %}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"outers": []int{1}, "inners": []int{1}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true},
+ )
+ varExprs := allExprs(result.Expressions, liquid.KindVariable)
+ for _, v := range varExprs {
+ if v.Variable != nil && v.Variable.Name == "inner" {
+ if v.Depth != 2 {
+ t.Errorf("inner variable Depth=%d, want 2 (nested for×for)", v.Depth)
+ }
+ }
+ }
+}
+
+// IF05 — MaxIterItems truncates inner expressions.
+func TestRenderAudit_Iteration_IF05_innerExpressionsAreTruncated(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3, 4, 5}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true, MaxIterationTraceItems: 2},
+ )
+ varExprs := allExprs(result.Expressions, liquid.KindVariable)
+ if len(varExprs) != 2 {
+ t.Errorf("variable expressions=%d, want 2 (only first 2 traced)", len(varExprs))
+ }
+}
+
+// IF06 — forloop special variables (forloop.index) can be traced as variables.
+func TestRenderAudit_Iteration_IF06_forloopVariables(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ forloop.index }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b"}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true},
+ )
+ // forloop.index should be accessible and traced.
+ varExprs := allExprs(result.Expressions, liquid.KindVariable)
+ if len(varExprs) == 0 {
+ t.Fatal("expected variable expressions for forloop.index")
+ }
+ // On first iteration, forloop.index should be 1.
+ first := varExprs[0]
+ if first.Variable != nil && sprintVal(first.Variable.Value) != "1" {
+ t.Errorf("forloop.index[0]=%v, want 1", first.Variable.Value)
+ }
+}
+
+// ============================================================================
+// IterationTrace — Tablerow (TR01–TR03)
+// ============================================================================
+
+// TR01 — tablerow produces an IterationTrace.
+func TestRenderAudit_Iteration_TR01_tablerow(t *testing.T) {
+ tpl := mustParseAudit(t, "{% tablerow item in items %}{{ item }}{% endtablerow %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("tablerow should produce an IterationTrace")
+ }
+ if it.Iteration.Variable != "item" {
+ t.Errorf("Variable=%q, want item", it.Iteration.Variable)
+ }
+ if it.Iteration.Length != 3 {
+ t.Errorf("Length=%d, want 3", it.Iteration.Length)
+ }
+}
+
+// TR02 — tablerow with cols: Length is correct and output contains table structure.
+func TestRenderAudit_Iteration_TR02_tablerowCols(t *testing.T) {
+ tpl := mustParseAudit(t, "{% tablerow item in items cols:2 %}{{ item }}{% endtablerow %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c", "d"}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Length != 4 {
+ t.Errorf("Length=%d, want 4", it.Iteration.Length)
+ }
+}
+
+// TR03 — tablerow with limit: Limit field populated.
+func TestRenderAudit_Iteration_TR03_tablerowLimit(t *testing.T) {
+ tpl := mustParseAudit(t, "{% tablerow item in items limit:2 %}{{ item }}{% endtablerow %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c", "d"}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Limit == nil {
+ t.Error("Limit should be non-nil for tablerow with limit:")
+ } else if *it.Iteration.Limit != 2 {
+ t.Errorf("*Limit=%d, want 2", *it.Iteration.Limit)
+ }
+}
+
+// ============================================================================
+// IterationTrace — Source, Range, Depth (IR01–IR03)
+// ============================================================================
+
+// IR01 — Source contains the {% for ... %} header.
+func TestRenderAudit_Iteration_IR01_sourceNonEmpty(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Source == "" {
+ t.Error("iteration Source should be non-empty")
+ }
+}
+
+// IR02 — Range.Start.Line is valid (>= 1).
+func TestRenderAudit_Iteration_IR02_rangeValid(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil {
+ t.Fatal("no iteration expression")
+ }
+ assertRangeValid(t, it.Range, "iteration Range")
+}
+
+// IR03 — top-level for has Depth=0; nested for inside if has Depth=1.
+func TestRenderAudit_Iteration_IR03_depth(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Depth != 0 {
+ t.Errorf("Depth=%d, want 0 for top-level for", it.Depth)
+ }
+}
+
+// ============================================================================
+// IterationTrace — Error/Edge Cases (IE01–IE05)
+// ============================================================================
+
+// IE01 — for over an int → not-iterable warning, zero iterations.
+func TestRenderAudit_Iteration_IE01_notIterableInt(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in orders %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"orders": 42},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic for for over int")
+ }
+ if d.Severity != liquid.SeverityWarning {
+ t.Errorf("severity=%q, want warning", d.Severity)
+ }
+ // Output should be empty (zero iterations).
+ assertOutput(t, result, "")
+}
+
+// IE02 — for over bool → not-iterable warning.
+func TestRenderAudit_Iteration_IE02_notIterableBool(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in flag %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"flag": true},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic for for over bool")
+ }
+}
+
+// IE03 — for over a string → not-iterable warning (string is not iterable in Liquid).
+func TestRenderAudit_Iteration_IE03_notIterableString(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in status %}{{ item }}{% endfor %}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"status": "pending"},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ d := firstDiag(result.Diagnostics, "not-iterable")
+ if d == nil {
+ t.Fatal("expected not-iterable diagnostic for for over string")
+ }
+}
+
+// IE04 — for-else: when collection is empty the else block runs, and no IterationTrace is emitted.
+// Note: the current implementation does NOT emit an IterationTrace for an empty collection
+// (no iterations to trace).
+func TestRenderAudit_Iteration_IE04_forElse_emptyCollection(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% else %}empty{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "empty")
+ // No iteration trace is emitted when there are zero iterations.
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it != nil && it.Iteration != nil && it.Iteration.Length != 0 {
+ t.Errorf("unexpected non-zero iteration length: %d", it.Iteration.Length)
+ }
+}
+
+// IE05 — for-else: when collection is non-empty, the else block does not run.
+func TestRenderAudit_Iteration_IE05_forElse_nonEmptyCollection(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% else %}empty{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"x"}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ assertOutput(t, result, "x")
+}
diff --git a/render_audit_misc_test.go b/render_audit_misc_test.go
new file mode 100644
index 00000000..8dda11c8
--- /dev/null
+++ b/render_audit_misc_test.go
@@ -0,0 +1,647 @@
+package liquid_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// AuditOptions — Flag Isolation (O01–O09)
+// ============================================================================
+
+// O01 — all flags false: Expressions is empty.
+func TestRenderAudit_Options_O01_allFlagsOff(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% if true %}yes{% endif %}{% for i in items %}{{ i }}{% endfor %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2}},
+ liquid.AuditOptions{}, // all false
+ )
+ if len(result.Expressions) != 0 {
+ t.Errorf("Expressions=%d, want 0 when all trace flags are false", len(result.Expressions))
+ }
+}
+
+// O02 — only TraceVariables: only KindVariable expressions.
+func TestRenderAudit_Options_O02_onlyVariables(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% if true %}yes{% endif %}{% for i in items %}{{ i }}{% endfor %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ for i, e := range result.Expressions {
+ if e.Kind != liquid.KindVariable {
+ t.Errorf("Expressions[%d].Kind=%q, want variable (only TraceVariables set)", i, e.Kind)
+ }
+ }
+}
+
+// O03 — only TraceConditions: only KindCondition expressions.
+func TestRenderAudit_Options_O03_onlyConditions(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% if true %}yes{% endif %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ for i, e := range result.Expressions {
+ if e.Kind != liquid.KindCondition {
+ t.Errorf("Expressions[%d].Kind=%q, want condition (only TraceConditions set)", i, e.Kind)
+ }
+ }
+}
+
+// O04 — only TraceIterations: only KindIteration expressions.
+func TestRenderAudit_Options_O04_onlyIterations(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% for i in items %}{{ i }}{% endfor %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ for i, e := range result.Expressions {
+ if e.Kind != liquid.KindIteration {
+ t.Errorf("Expressions[%d].Kind=%q, want iteration (only TraceIterations set)", i, e.Kind)
+ }
+ }
+}
+
+// O05 — only TraceAssignments: KindAssignment and KindCapture, no others.
+func TestRenderAudit_Options_O05_onlyAssignments(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% capture y %}hi{% endcapture %}{% if true %}yes{% endif %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ for i, e := range result.Expressions {
+ if e.Kind != liquid.KindAssignment && e.Kind != liquid.KindCapture {
+ t.Errorf("Expressions[%d].Kind=%q, want assignment or capture (only TraceAssignments set)", i, e.Kind)
+ }
+ }
+}
+
+// O06 — all flags true: all kinds of expressions appear in a rich template.
+func TestRenderAudit_Options_O06_allFlagsOn(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% if true %}yes{% endif %}{% for i in items %}{{ i }}{% endfor %}{% capture z %}cap{% endcapture %}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{
+ TraceVariables: true,
+ TraceConditions: true,
+ TraceIterations: true,
+ TraceAssignments: true,
+ },
+ )
+ kinds := make(map[liquid.ExpressionKind]bool)
+ for _, e := range result.Expressions {
+ kinds[e.Kind] = true
+ }
+ expectedKinds := []liquid.ExpressionKind{
+ liquid.KindVariable,
+ liquid.KindCondition,
+ liquid.KindIteration,
+ liquid.KindAssignment,
+ liquid.KindCapture,
+ }
+ for _, k := range expectedKinds {
+ if !kinds[k] {
+ t.Errorf("missing expression kind %q in all-flags-on audit", k)
+ }
+ }
+}
+
+// O07 — Diagnostics are always collected regardless of trace flags.
+func TestRenderAudit_Options_O07_diagnosticsAlwaysCollected(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 10 | divided_by: 0 }}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{ /* all flags false */ })
+ if len(result.Diagnostics) == 0 {
+ t.Error("Diagnostics should be collected even when all trace flags are false")
+ }
+}
+
+// O08 — MaxIterationTraceItems=0 with all flags → no truncation.
+func TestRenderAudit_Options_O08_maxIterUnlimited(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for i in items %}{{ i }}{% endfor %}")
+ items := make([]int, 50)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": items},
+ liquid.AuditOptions{
+ TraceIterations: true,
+ MaxIterationTraceItems: 0,
+ },
+ )
+ it := firstExpr(result.Expressions, liquid.KindIteration)
+ if it == nil || it.Iteration == nil {
+ t.Fatal("no iteration expression")
+ }
+ if it.Iteration.Truncated {
+ t.Error("Truncated should be false when MaxIterationTraceItems=0 (unlimited)")
+ }
+}
+
+// O09 — trace flags do not affect Output correctness.
+func TestRenderAudit_Options_O09_flagsDontAffectOutput(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}{% if true %}yes{% endif %}{% for i in items %}{{ i }}{% endfor %}`)
+ bindings := liquid.Bindings{"items": []int{1, 2}}
+
+ // Get expected output without audit.
+ eng := newAuditEngine()
+ expected, se := eng.ParseAndRenderString(
+ `{% assign x = "hi" %}{{ x }}{% if true %}yes{% endif %}{% for i in items %}{{ i }}{% endfor %}`,
+ bindings,
+ )
+ if se != nil {
+ t.Fatalf("baseline render error: %v", se)
+ }
+
+ // Render with all flags on should produce the same output.
+ result := auditOK(t, tpl, bindings,
+ liquid.AuditOptions{
+ TraceVariables: true,
+ TraceConditions: true,
+ TraceIterations: true,
+ TraceAssignments: true,
+ },
+ )
+ if result.Output != expected {
+ t.Errorf("Output with audit=%q, want %q (identical to Render)", result.Output, expected)
+ }
+}
+
+// ============================================================================
+// AuditResult — Output (R01–R04)
+// ============================================================================
+
+// R01 — Output matches Render for a simple template.
+func TestRenderAudit_Result_R01_outputMatchesRender(t *testing.T) {
+ src := "Hello, {{ name }}!"
+ bindings := liquid.Bindings{"name": "World"}
+ eng := newAuditEngine()
+ expected, _ := eng.ParseAndRenderString(src, bindings)
+
+ tpl := mustParseAuditWith(t, eng, src)
+ result := auditOK(t, tpl, bindings, liquid.AuditOptions{})
+ if result.Output != expected {
+ t.Errorf("Output=%q, want %q", result.Output, expected)
+ }
+}
+
+// R02 — Output matches Render for a complex template with assign, for, if.
+func TestRenderAudit_Result_R02_complexOutputMatchesRender(t *testing.T) {
+ src := `{% assign greeting = "Hello" %}{% for name in names %}{% if name == "Alice" %}{{ greeting }}, {{ name }}!{% else %}Hi, {{ name }}.{% endif %}{% endfor %}`
+ bindings := liquid.Bindings{"names": []string{"Alice", "Bob"}}
+
+ eng := newAuditEngine()
+ expected, _ := eng.ParseAndRenderString(src, bindings)
+
+ tpl := mustParseAuditWith(t, eng, src)
+ result := auditOK(t, tpl, bindings,
+ liquid.AuditOptions{
+ TraceVariables: true,
+ TraceConditions: true,
+ TraceIterations: true,
+ TraceAssignments: true,
+ },
+ )
+ if result.Output != expected {
+ t.Errorf("Output=%q, want %q", result.Output, expected)
+ }
+}
+
+// R03 — AuditResult is always non-nil even on error; Output may be partial.
+// Note: divided_by:0 filter errors are captured as Diagnostics; they do NOT produce
+// an AuditError — the render continues and emits partial output.
+func TestRenderAudit_Result_R03_nonNilOnError(t *testing.T) {
+ tpl := mustParseAudit(t, "before{{ 10 | divided_by: 0 }}after")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if result == nil {
+ t.Fatal("AuditResult must never be nil")
+ }
+ // ae is nil for filter errors (captured as Diagnostics only).
+ _ = ae
+ // Output should contain at least the non-error text.
+ if result.Output != "beforeafter" {
+ t.Errorf("Output=%q, want \"beforeafter\" (error part skipped)", result.Output)
+ }
+ // Diagnostic should be present.
+ if len(result.Diagnostics) == 0 {
+ t.Error("expected at least one Diagnostic for divided_by:0")
+ }
+}
+
+// R04 — empty template produces empty Output and zero Expressions.
+func TestRenderAudit_Result_R04_emptyTemplate(t *testing.T) {
+ tpl := mustParseAudit(t, "")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ if result.Output != "" {
+ t.Errorf("Output=%q, want empty for empty template", result.Output)
+ }
+ if len(result.Expressions) != 0 {
+ t.Errorf("Expressions=%d, want 0 for empty template", len(result.Expressions))
+ }
+}
+
+// ============================================================================
+// AuditResult — Expressions Ordering (RO01–RO05)
+// ============================================================================
+
+// RO01 — assign appears before variable in execution order.
+func TestRenderAudit_Result_RO01_assignBeforeVariable(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign msg = "hello" %}{{ msg }}`)
+ result := auditOK(t, tpl, liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ if len(result.Expressions) < 2 {
+ t.Fatalf("expected >= 2 expressions")
+ }
+ if result.Expressions[0].Kind != liquid.KindAssignment {
+ t.Errorf("Expressions[0].Kind=%q, want assignment", result.Expressions[0].Kind)
+ }
+ if result.Expressions[1].Kind != liquid.KindVariable {
+ t.Errorf("Expressions[1].Kind=%q, want variable", result.Expressions[1].Kind)
+ }
+}
+
+// RO02 — for loop: inner variable expressions are emitted BEFORE the iteration's final trace.
+// Ordering: 3 × KindVariable (one per iteration), then 1 × KindIteration (summary at the end).
+func TestRenderAudit_Result_RO02_forLoopLinearized(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true},
+ )
+ // Pattern: 3 × KindVariable (iterations emit body traces first), then KindIteration.
+ if len(result.Expressions) < 4 {
+ t.Fatalf("expected >= 4 expressions, got %d", len(result.Expressions))
+ }
+ // Variables come first (body traces from iterations).
+ for i := range 3 {
+ if result.Expressions[i].Kind != liquid.KindVariable {
+ t.Errorf("Expressions[%d].Kind=%q, want variable", i, result.Expressions[i].Kind)
+ }
+ }
+ // Iteration trace is the last expression.
+ last := result.Expressions[len(result.Expressions)-1]
+ if last.Kind != liquid.KindIteration {
+ t.Errorf("last expression Kind=%q, want iteration", last.Kind)
+ }
+}
+
+// RO03 — in if(true), inner expression traces exist; in if(false), inner traces DO NOT exist.
+func TestRenderAudit_Result_RO03_onlyExecutedBranchTraced(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if flag %}{{ inside_true }}{% else %}{{ inside_false }}{% endif %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"flag": true, "inside_true": "yes", "inside_false": "no"},
+ liquid.AuditOptions{TraceConditions: true, TraceVariables: true},
+ )
+ for _, e := range result.Expressions {
+ if e.Kind == liquid.KindVariable && e.Variable != nil && e.Variable.Name == "inside_false" {
+ t.Error("inside_false should not be traced (unexecuted else branch)")
+ }
+ }
+ found := false
+ for _, e := range result.Expressions {
+ if e.Kind == liquid.KindVariable && e.Variable != nil && e.Variable.Name == "inside_true" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("inside_true should be traced (executed if branch)")
+ }
+}
+
+// ============================================================================
+// AuditResult — JSON Serialization (RJ01–RJ04)
+// ============================================================================
+
+// RJ01 — AuditResult serializes to JSON without error.
+func TestRenderAudit_Result_RJ01_jsonMarshal(t *testing.T) {
+ tpl := mustParseAudit(t, `{% assign x = "hi" %}{{ x }}`)
+ result := auditOK(t, tpl, liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ b, err := json.Marshal(result)
+ if err != nil {
+ t.Fatalf("json.Marshal(AuditResult): %v", err)
+ }
+ if len(b) == 0 {
+ t.Error("marshaled JSON should not be empty")
+ }
+}
+
+// RJ02 — JSON output contains snake_case keys matching the spec.
+func TestRenderAudit_Result_RJ02_jsonKeys(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ b, _ := json.Marshal(result)
+ s := string(b)
+ expectedKeys := []string{"output", "expressions", "diagnostics", "traced_count"}
+ for _, key := range expectedKeys {
+ found := false
+ for i := 0; i < len(s)-len(key); i++ {
+ if s[i:i+len(key)] == key {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected key %q in JSON output: %s", key, s)
+ }
+ }
+}
+
+// RJ03 — omitempty works: nil optional fields are omitted from JSON.
+func TestRenderAudit_Result_RJ03_omitempty(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}x{% endfor %}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ b, _ := json.Marshal(result)
+ s := string(b)
+ // Limit and Offset should be absent when nil (omitempty).
+ if contains(s, `"limit":null`) {
+ t.Error(`"limit":null should be omitted (omitempty), not present as null`)
+ }
+ if contains(s, `"offset":null`) {
+ t.Error(`"offset":null should be omitted (omitempty), not present as null`)
+ }
+}
+
+// RJ04 — roundtrip: marshal → unmarshal → Output preserved.
+func TestRenderAudit_Result_RJ04_roundtrip(t *testing.T) {
+ tpl := mustParseAudit(t, "Hello, {{ name }}!")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"name": "World"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ b, err := json.Marshal(result)
+ if err != nil {
+ t.Fatalf("json.Marshal: %v", err)
+ }
+ var decoded liquid.AuditResult
+ if err := json.Unmarshal(b, &decoded); err != nil {
+ t.Fatalf("json.Unmarshal: %v", err)
+ }
+ if decoded.Output != result.Output {
+ t.Errorf("roundtrip Output=%q, want %q", decoded.Output, result.Output)
+ }
+}
+
+// ============================================================================
+// Validate — Static Analysis (VA01–VA12)
+// ============================================================================
+
+// VA01 — empty if block: info-level empty-block diagnostic.
+func TestRenderAudit_Validate_VA01_emptyIf(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if true %}{% endif %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := firstDiag(result.Diagnostics, "empty-block")
+ if d == nil {
+ t.Fatal("expected empty-block diagnostic")
+ }
+ if d.Severity != liquid.SeverityInfo {
+ t.Errorf("Severity=%q, want info for empty-block", d.Severity)
+ }
+}
+
+// VA02 — empty unless block: info-level empty-block.
+func TestRenderAudit_Validate_VA02_emptyUnless(t *testing.T) {
+ tpl := mustParseAudit(t, "{% unless true %}{% endunless %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := firstDiag(result.Diagnostics, "empty-block")
+ if d == nil {
+ t.Fatal("expected empty-block for empty unless block")
+ }
+}
+
+// VA03 — empty for block: info-level empty-block.
+func TestRenderAudit_Validate_VA03_emptyFor(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for x in items %}{% endfor %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := firstDiag(result.Diagnostics, "empty-block")
+ if d == nil {
+ t.Fatal("expected empty-block for empty for block")
+ }
+}
+
+// VA05 — normal template with content: no empty-block.
+func TestRenderAudit_Validate_VA05_normalTemplate(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}hello{% endif %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := firstDiag(result.Diagnostics, "empty-block")
+ if d != nil {
+ t.Errorf("unexpected empty-block diagnostic for non-empty if block")
+ }
+}
+
+// VA06 — undefined filter: error-level diagnostic.
+func TestRenderAudit_Validate_VA06_undefinedFilter(t *testing.T) {
+ eng := liquid.NewEngine()
+ tpl := mustParseAuditWith(t, eng, "{{ name | no_such_filter }}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := firstDiag(result.Diagnostics, "undefined-filter")
+ if d == nil {
+ t.Fatal("expected undefined-filter diagnostic")
+ }
+ if d.Severity != liquid.SeverityError {
+ t.Errorf("Severity=%q, want error for undefined-filter", d.Severity)
+ }
+}
+
+// VA07 — defined filter (upcase): no undefined-filter diagnostic.
+func TestRenderAudit_Validate_VA07_definedFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name | upcase }}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := firstDiag(result.Diagnostics, "undefined-filter")
+ if d != nil {
+ t.Error("unexpected undefined-filter diagnostic for standard upcase filter")
+ }
+}
+
+// VA08 — Validate returns empty Output string (does not render).
+func TestRenderAudit_Validate_VA08_noOutput(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name }}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Output != "" {
+ t.Errorf("Validate Output=%q, want empty (no rendering)", result.Output)
+ }
+}
+
+// VA09 — Validate returns empty Expressions (no execution).
+func TestRenderAudit_Validate_VA09_noExpressions(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name }}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Expressions) != 0 {
+ t.Errorf("Validate Expressions len=%d, want 0 (no execution)", len(result.Expressions))
+ }
+}
+
+// VA10 — multiple empty blocks detected together.
+func TestRenderAudit_Validate_VA10_multipleEmptyBlocks(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}{% endif %}{% for y in items %}{% endfor %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ emptyBlocks := allDiags(result.Diagnostics, "empty-block")
+ if len(emptyBlocks) < 2 {
+ t.Errorf("expected >= 2 empty-block diagnostics, got %d", len(emptyBlocks))
+ }
+}
+
+// VA11 — block with only whitespace: may or may not be empty-block (implementation-defined).
+// The test documents the behavior, not requires a specific outcome.
+func TestRenderAudit_Validate_VA11_whitespaceOnlyBlock(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %} {% endif %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Just verify no panic and result is non-nil.
+ if result == nil {
+ t.Fatal("Validate result must not be nil")
+ }
+ t.Logf("whitespace-only if block: empty-block count=%d (implementation-defined)",
+ len(allDiags(result.Diagnostics, "empty-block")))
+}
+
+// VA12 — nested empty block: inner empty block detected.
+func TestRenderAudit_Validate_VA12_nestedEmptyBlock(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if x %}{% if y %}{% endif %}{% endif %}")
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ emptyBlocks := allDiags(result.Diagnostics, "empty-block")
+ if len(emptyBlocks) < 1 {
+ t.Error("expected at least 1 empty-block diagnostic for inner empty if")
+ }
+}
+
+// ============================================================================
+// RenderOptions Interaction (RO01–RO06)
+// ============================================================================
+
+// RO01 — WithStrictVariables: undefined-variable captured as warning.
+func TestRenderAudit_RenderOpts_RO01_strictVariables(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ undefined }}")
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if ae == nil {
+ t.Fatal("expected AuditError with StrictVariables")
+ }
+ d := firstDiag(result.Diagnostics, "undefined-variable")
+ if d == nil {
+ t.Fatal("expected undefined-variable diagnostic")
+ }
+}
+
+// RO02 — without StrictVariables: no diagnostic for undefined.
+func TestRenderAudit_RenderOpts_RO02_noStrict(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ undefined }}")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{})
+ if ae != nil {
+ t.Fatalf("unexpected AuditError without StrictVariables: %v", ae)
+ }
+ if len(result.Diagnostics) > 0 {
+ t.Errorf("expected no diagnostics without StrictVariables, got %v", result.Diagnostics)
+ }
+}
+
+// RO03 — WithLaxFilters: unknown filter does not produce an error.
+func TestRenderAudit_RenderOpts_RO03_laxFilters(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name | unknown_filter }}")
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"name": "Alice"},
+ liquid.AuditOptions{},
+ liquid.WithLaxFilters(),
+ )
+ if ae != nil {
+ t.Fatalf("unexpected AuditError with LaxFilters: %v", ae)
+ }
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+}
+
+// RO04 — WithGlobals: global variables accessible.
+func TestRenderAudit_RenderOpts_RO04_withGlobals(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ site_name }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceVariables: true},
+ liquid.WithGlobals(map[string]any{"site_name": "My Site"}),
+ )
+ assertOutput(t, result, "My Site")
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "My Site" {
+ t.Errorf("Value=%v, want My Site", v.Variable.Value)
+ }
+}
+
+// RO05 — WithSizeLimit: output is limited but trace still collected.
+func TestRenderAudit_RenderOpts_RO05_sizeLimit(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a }}{{ b }}")
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"a": "hello", "b": "world"},
+ liquid.AuditOptions{TraceVariables: true},
+ liquid.WithSizeLimit(5), // limit to 5 bytes
+ )
+ if result == nil {
+ t.Fatal("result must not be nil")
+ }
+ // Output should be truncated.
+ if len(result.Output) > 5 {
+ t.Errorf("Output len=%d, want <= 5 (size limit)", len(result.Output))
+ }
+}
+
+// ============================================================================
+// Helper function used in JSON tests
+// ============================================================================
+
+func contains(s, sub string) bool {
+ return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
+}
+
+func containsStr(s, sub string) bool {
+ for i := 0; i <= len(s)-len(sub); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
diff --git a/render_audit_test.go b/render_audit_test.go
new file mode 100644
index 00000000..c9cdb574
--- /dev/null
+++ b/render_audit_test.go
@@ -0,0 +1,1199 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// newEngine is a test helper that creates a default Engine.
+func newAuditEngine() *liquid.Engine {
+ return liquid.NewEngine()
+}
+
+// --------------------------------------------------------------------------
+// RenderAudit — TraceVariables
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_TraceVariables_simple(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString("Hello, {{ name }}!")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"name": "Alice"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "Hello, Alice!" {
+ t.Errorf("output = %q, want %q", result.Output, "Hello, Alice!")
+ }
+ if len(result.Expressions) != 1 {
+ t.Fatalf("len(Expressions) = %d, want 1", len(result.Expressions))
+ }
+ e := result.Expressions[0]
+ if e.Kind != liquid.KindVariable {
+ t.Errorf("Kind = %q, want %q", e.Kind, liquid.KindVariable)
+ }
+ if e.Variable == nil {
+ t.Fatal("Variable is nil")
+ }
+ if e.Variable.Name != "name" {
+ t.Errorf("Variable.Name = %q, want %q", e.Variable.Name, "name")
+ }
+ if e.Variable.Value != "Alice" {
+ t.Errorf("Variable.Value = %v, want %q", e.Variable.Value, "Alice")
+ }
+}
+
+func TestRenderAudit_TraceVariables_noTrace(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString("Hello, {{ name }}!")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // TraceVariables not set → Expressions should be empty.
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"name": "Bob"},
+ liquid.AuditOptions{},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "Hello, Bob!" {
+ t.Errorf("output = %q, want %q", result.Output, "Hello, Bob!")
+ }
+ if len(result.Expressions) != 0 {
+ t.Errorf("len(Expressions) = %d, want 0", len(result.Expressions))
+ }
+}
+
+func TestRenderAudit_TraceVariables_filterPipeline(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{{ name | upcase }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"name": "alice"},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "ALICE" {
+ t.Errorf("output = %q, want %q", result.Output, "ALICE")
+ }
+ if len(result.Expressions) == 0 {
+ t.Fatal("Expressions is empty")
+ }
+ e := result.Expressions[0]
+ if e.Variable == nil {
+ t.Fatal("Variable is nil")
+ }
+ if len(e.Variable.Pipeline) == 0 {
+ t.Fatal("Pipeline is empty, expected at least one filter step")
+ }
+ step := e.Variable.Pipeline[0]
+ if step.Filter != "upcase" {
+ t.Errorf("Pipeline[0].Filter = %q, want %q", step.Filter, "upcase")
+ }
+ if step.Input != "alice" {
+ t.Errorf("Pipeline[0].Input = %v, want %q", step.Input, "alice")
+ }
+ if step.Output != "ALICE" {
+ t.Errorf("Pipeline[0].Output = %v, want %q", step.Output, "ALICE")
+ }
+}
+
+func TestRenderAudit_TraceVariables_depth(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if true %}{{ x }}{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": 42},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if len(result.Expressions) == 0 {
+ t.Fatal("Expressions is empty")
+ }
+ e := result.Expressions[0]
+ if e.Depth != 1 {
+ t.Errorf("Depth = %d, want 1 (inside if block)", e.Depth)
+ }
+}
+
+// --------------------------------------------------------------------------
+// RenderAudit — TraceConditions
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_TraceConditions_if_taken(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if x %}yes{% else %}no{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": true},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "yes" {
+ t.Errorf("output = %q, want %q", result.Output, "yes")
+ }
+
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression found")
+ }
+ if condExpr.Condition == nil {
+ t.Fatal("Condition is nil")
+ }
+ branches := condExpr.Condition.Branches
+ if len(branches) != 2 {
+ t.Fatalf("len(Branches) = %d, want 2 (if + else)", len(branches))
+ }
+ if !branches[0].Executed {
+ t.Error("branches[0].Executed should be true (if branch taken)")
+ }
+ if branches[1].Executed {
+ t.Error("branches[1].Executed should be false (else not taken)")
+ }
+}
+
+func TestRenderAudit_TraceConditions_else_taken(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if x %}yes{% else %}no{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": false},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "no" {
+ t.Errorf("output = %q, want %q", result.Output, "no")
+ }
+
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression found")
+ }
+ branches := condExpr.Condition.Branches
+ if branches[0].Executed {
+ t.Error("if branch should not be executed")
+ }
+ if !branches[1].Executed {
+ t.Error("else branch should be executed")
+ }
+}
+
+func TestRenderAudit_TraceConditions_unless(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% unless disabled %}active{% endunless %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"disabled": false},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "active" {
+ t.Errorf("output = %q, want %q", result.Output, "active")
+ }
+
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression found")
+ }
+ branches := condExpr.Condition.Branches
+ if len(branches) == 0 {
+ t.Fatal("no branches")
+ }
+ if branches[0].Kind != "unless" {
+ t.Errorf("branches[0].Kind = %q, want %q", branches[0].Kind, "unless")
+ }
+}
+
+// --------------------------------------------------------------------------
+// RenderAudit — TraceIterations
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_TraceIterations_basic(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% for item in items %}{{ item }}{% endfor %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceIterations: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "abc" {
+ t.Errorf("output = %q, want %q", result.Output, "abc")
+ }
+
+ var iterExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindIteration {
+ iterExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if iterExpr == nil {
+ t.Fatal("no iteration expression found")
+ }
+ it := iterExpr.Iteration
+ if it == nil {
+ t.Fatal("Iteration is nil")
+ }
+ if it.Variable != "item" {
+ t.Errorf("Variable = %q, want %q", it.Variable, "item")
+ }
+ if it.Collection != "items" {
+ t.Errorf("Collection = %q, want %q", it.Collection, "items")
+ }
+ if it.Length != 3 {
+ t.Errorf("Length = %d, want 3", it.Length)
+ }
+}
+
+// --------------------------------------------------------------------------
+// RenderAudit — TraceAssignments
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_TraceAssignments_assign(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% assign greeting = "Hello" %}{{ greeting }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "Hello" {
+ t.Errorf("output = %q, want %q", result.Output, "Hello")
+ }
+
+ var assignExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindAssignment {
+ assignExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if assignExpr == nil {
+ t.Fatal("no assignment expression found")
+ }
+ a := assignExpr.Assignment
+ if a == nil {
+ t.Fatal("Assignment is nil")
+ }
+ if a.Variable != "greeting" {
+ t.Errorf("Variable = %q, want %q", a.Variable, "greeting")
+ }
+ if a.Value != "Hello" {
+ t.Errorf("Value = %v, want %q", a.Value, "Hello")
+ }
+}
+
+func TestRenderAudit_TraceAssignments_capture(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% capture msg %}Hi there!{% endcapture %}{{ msg }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "Hi there!" {
+ t.Errorf("output = %q, want %q", result.Output, "Hi there!")
+ }
+
+ var capExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCapture {
+ capExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if capExpr == nil {
+ t.Fatal("no capture expression found")
+ }
+ c := capExpr.Capture
+ if c == nil {
+ t.Fatal("Capture is nil")
+ }
+ if c.Variable != "msg" {
+ t.Errorf("Variable = %q, want %q", c.Variable, "msg")
+ }
+ if c.Value != "Hi there!" {
+ t.Errorf("Value = %q, want %q", c.Value, "Hi there!")
+ }
+}
+
+// --------------------------------------------------------------------------
+// RenderAudit — combined trace
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_Combined(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% assign total = price | times: 2 %}{{ total }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"price": 10},
+ liquid.AuditOptions{TraceAssignments: true, TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if result.Output != "20" {
+ t.Errorf("output = %q, want %q", result.Output, "20")
+ }
+
+ // Should have 2 expressions: an assignment and a variable trace.
+ if len(result.Expressions) != 2 {
+ t.Fatalf("len(Expressions) = %d, want 2", len(result.Expressions))
+ }
+ kinds := make(map[liquid.ExpressionKind]bool)
+ for _, e := range result.Expressions {
+ kinds[e.Kind] = true
+ }
+ if !kinds[liquid.KindAssignment] {
+ t.Error("missing KindAssignment expression")
+ }
+ if !kinds[liquid.KindVariable] {
+ t.Error("missing KindVariable expression")
+ }
+}
+
+// --------------------------------------------------------------------------
+// RenderAudit — AuditError
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_Error_strictVariables(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{{ ghost }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ // Result is always returned.
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ if ae == nil {
+ t.Fatal("expected AuditError, got nil")
+ }
+ if len(ae.Errors()) == 0 {
+ t.Error("AuditError.Errors() is empty")
+ }
+ if ae.Error() == "" {
+ t.Error("AuditError.Error() is empty")
+ }
+}
+
+func TestRenderAudit_ResultNonNilOnError(t *testing.T) {
+ eng := newAuditEngine()
+ // Template that will fail with strict variables.
+ tpl, err := eng.ParseString(`before {{ missing }} after`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if result == nil {
+ t.Fatal("result must never be nil")
+ }
+ if ae == nil {
+ t.Fatal("expected AuditError")
+ }
+ // Output may be partial but should not panic.
+ _ = result.Output
+}
+
+// --------------------------------------------------------------------------
+// Validate
+// --------------------------------------------------------------------------
+
+func TestValidate_emptyIF(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, parseErr := eng.ParseString(`{% if true %}{% endif %}`)
+ if parseErr != nil {
+ t.Fatal(parseErr)
+ }
+
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatalf("Validate error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ // Should have at least one info-level empty-block diagnostic.
+ found := false
+ for _, d := range result.Diagnostics {
+ if d.Code == "empty-block" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("expected empty-block diagnostic, got none")
+ }
+}
+
+func TestValidate_nonEmpty(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, parseErr := eng.ParseString(`{% if true %}hello{% endif %}`)
+ if parseErr != nil {
+ t.Fatal(parseErr)
+ }
+
+ result, err := tpl.Validate()
+ if err != nil {
+ t.Fatalf("Validate error: %v", err)
+ }
+ // No diagnostics expected for a non-empty block.
+ for _, d := range result.Diagnostics {
+ if d.Code == "empty-block" {
+ t.Errorf("unexpected empty-block diagnostic: %s", d.Message)
+ }
+ }
+}
+
+// --------------------------------------------------------------------------
+// Position / Range
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_Position_lineNumber(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString("line1\nline2\n{{ x }}")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": 1},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if len(result.Expressions) == 0 {
+ t.Fatal("no expressions")
+ }
+
+ pos := result.Expressions[0].Range.Start
+ if pos.Line != 3 {
+ t.Errorf("Start.Line = %d, want 3", pos.Line)
+ }
+}
+
+// --------------------------------------------------------------------------
+// Gap-fix tests: assign source location and filter pipeline
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_AssignSourceLoc(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% assign x = "hello" %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if len(result.Expressions) == 0 {
+ t.Fatal("no expressions")
+ }
+ e := result.Expressions[0]
+ if e.Kind != liquid.KindAssignment {
+ t.Fatalf("Kind = %q, want assignment", e.Kind)
+ }
+ // The range should have a real line number (not 0).
+ if e.Range.Start.Line == 0 {
+ t.Errorf("Range.Start.Line = 0, want a real line number (≥1)")
+ }
+}
+
+func TestRenderAudit_AssignFilterPipeline(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% assign upper = name | upcase %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"name": "alice"},
+ liquid.AuditOptions{TraceAssignments: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ if len(result.Expressions) == 0 {
+ t.Fatal("no expressions")
+ }
+ a := result.Expressions[0].Assignment
+ if a == nil {
+ t.Fatal("Assignment is nil")
+ }
+ if len(a.Pipeline) == 0 {
+ t.Fatal("Pipeline is empty — filter steps not captured for assign")
+ }
+ if a.Pipeline[0].Filter != "upcase" {
+ t.Errorf("Pipeline[0].Filter = %q, want %q", a.Pipeline[0].Filter, "upcase")
+ }
+ if a.Pipeline[0].Input != "alice" {
+ t.Errorf("Pipeline[0].Input = %v, want %q", a.Pipeline[0].Input, "alice")
+ }
+ if a.Pipeline[0].Output != "ALICE" {
+ t.Errorf("Pipeline[0].Output = %v, want %q", a.Pipeline[0].Output, "ALICE")
+ }
+ if a.Value != "ALICE" {
+ t.Errorf("Value = %v, want %q", a.Value, "ALICE")
+ }
+}
+
+// --------------------------------------------------------------------------
+// Gap-fix tests: MaxIterationTraceItems and TracedCount
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_MaxIterItems_TracedCount(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% for item in items %}{{ item }}{% endfor %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ items := []int{1, 2, 3, 4, 5}
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"items": items},
+ liquid.AuditOptions{
+ TraceIterations: true,
+ TraceVariables: true,
+ MaxIterationTraceItems: 2,
+ },
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ // Output should still be complete (MaxIterItems only limits tracing, not rendering).
+ if result.Output != "12345" {
+ t.Errorf("output = %q, want %q", result.Output, "12345")
+ }
+
+ // Find the iteration expression.
+ var iterExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindIteration {
+ iterExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if iterExpr == nil {
+ t.Fatal("no iteration expression")
+ }
+ it := iterExpr.Iteration
+ if it == nil {
+ t.Fatal("Iteration is nil")
+ }
+ if it.Length != 5 {
+ t.Errorf("Length = %d, want 5", it.Length)
+ }
+ if it.TracedCount != 2 {
+ t.Errorf("TracedCount = %d, want 2 (limited by MaxIterationTraceItems)", it.TracedCount)
+ }
+ if !it.Truncated {
+ t.Error("Truncated should be true")
+ }
+
+ // Only 2 variable expressions should appear (one per traced iteration).
+ varCount := 0
+ for _, e := range result.Expressions {
+ if e.Kind == liquid.KindVariable {
+ varCount++
+ }
+ }
+ if varCount != 2 {
+ t.Errorf("variable expression count = %d, want 2 (only traced iterations)", varCount)
+ }
+}
+
+func TestRenderAudit_NoMaxIterItems_AllTraced(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% for item in items %}{{ item }}{% endfor %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"items": []int{1, 2, 3}},
+ liquid.AuditOptions{TraceIterations: true, TraceVariables: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ var iterExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindIteration {
+ iterExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if iterExpr == nil {
+ t.Fatal("no iteration expression")
+ }
+ if iterExpr.Iteration.TracedCount != 3 {
+ t.Errorf("TracedCount = %d, want 3", iterExpr.Iteration.TracedCount)
+ }
+ if iterExpr.Iteration.Truncated {
+ t.Error("Truncated should be false when no limit set")
+ }
+}
+
+// --------------------------------------------------------------------------
+// Gap-fix tests: ConditionBranch.Comparisons
+// --------------------------------------------------------------------------
+
+func TestRenderAudit_ConditionComparisons_simple(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if x >= 10 %}big{% else %}small{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": 15},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression")
+ }
+ branches := condExpr.Condition.Branches
+ if len(branches) == 0 {
+ t.Fatal("no branches")
+ }
+
+ // The if branch should have items with a leaf comparison.
+ ifBranch := branches[0]
+ if len(ifBranch.Items) == 0 {
+ t.Fatal("if branch has no items — comparison tracing not working")
+ }
+ cmpItem := ifBranch.Items[0].Comparison
+ if cmpItem == nil {
+ t.Fatal("first item is not a comparison")
+ }
+ if cmpItem.Operator != ">=" {
+ t.Errorf("Operator = %q, want %q", cmpItem.Operator, ">=")
+ }
+ if cmpItem.Left != 15 {
+ t.Errorf("Left = %v, want 15", cmpItem.Left)
+ }
+ if cmpItem.Right != 10 {
+ t.Errorf("Right = %v, want 10", cmpItem.Right)
+ }
+ if !cmpItem.Result {
+ t.Error("Result should be true (15 >= 10)")
+ }
+}
+
+func TestRenderAudit_ConditionComparisons_else_noComparisons(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if x > 100 %}big{% else %}small{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": 5},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression")
+ }
+ branches := condExpr.Condition.Branches
+
+ // else branch should have no items.
+ for _, b := range branches {
+ if b.Kind == "else" && len(b.Items) > 0 {
+ t.Errorf("else branch should have 0 items, got %d", len(b.Items))
+ }
+ }
+}
+
+func TestRenderAudit_ConditionComparisons_equality(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if status == "active" %}yes{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"status": "active"},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression")
+ }
+ if len(condExpr.Condition.Branches) == 0 {
+ t.Fatal("no branches")
+ }
+ items := condExpr.Condition.Branches[0].Items
+ if len(items) == 0 {
+ t.Fatal("no items for == expression")
+ }
+ if items[0].Comparison == nil {
+ t.Fatal("first item is not a comparison")
+ }
+ if items[0].Comparison.Operator != "==" {
+ t.Errorf("Operator = %q, want %q", items[0].Comparison.Operator, "==")
+ }
+}
+
+func TestRenderAudit_ConditionComparisons_groupTrace_and(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if x >= 10 and y < 5 %}yes{% else %}no{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": 15, "y": 3},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil {
+ t.Fatal("no condition expression")
+ }
+ branches := condExpr.Condition.Branches
+ if len(branches) == 0 {
+ t.Fatal("no branches")
+ }
+ // The if branch (index 0) should have one item: a GroupTrace for and.
+ ifItems := branches[0].Items
+ if len(ifItems) == 0 {
+ t.Fatal("if branch has no items")
+ }
+ group := ifItems[0].Group
+ if group == nil {
+ t.Fatalf("expected a GroupTrace at items[0], got comparison %+v", ifItems[0].Comparison)
+ }
+ if group.Operator != "and" {
+ t.Errorf("group.Operator = %q, want \"and\"", group.Operator)
+ }
+ if !group.Result {
+ t.Error("group.Result should be true (15 >= 10 and 3 < 5)")
+ }
+ // The group should contain exactly two child items (the >= and < comparisons).
+ if len(group.Items) != 2 {
+ t.Fatalf("group.Items len = %d, want 2", len(group.Items))
+ }
+ // First child: the >= comparison.
+ geCmp := group.Items[0].Comparison
+ if geCmp == nil {
+ t.Fatal("group.Items[0] should be a Comparison, got Group")
+ }
+ if geCmp.Operator != ">=" {
+ t.Errorf("group.Items[0].Comparison.Operator = %q, want \">=\"", geCmp.Operator)
+ }
+ // Second child: the < comparison.
+ ltCmp := group.Items[1].Comparison
+ if ltCmp == nil {
+ t.Fatal("group.Items[1] should be a Comparison, got Group")
+ }
+ if ltCmp.Operator != "<" {
+ t.Errorf("group.Items[1].Comparison.Operator = %q, want \"<\"", ltCmp.Operator)
+ }
+}
+
+func TestRenderAudit_Diagnostic_undefinedVariable(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{{ ghost }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ liquid.WithStrictVariables(),
+ )
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "undefined-variable" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected diagnostic code \"undefined-variable\", got: %v", result.Diagnostics)
+ }
+ if found.Severity != liquid.SeverityWarning {
+ t.Errorf("severity = %q, want %q", found.Severity, liquid.SeverityWarning)
+ }
+}
+
+func TestRenderAudit_Diagnostic_argumentError(t *testing.T) {
+ eng := newAuditEngine()
+ // divided_by: 0 produces a ZeroDivisionError which maps to "argument-error".
+ tpl, err := eng.ParseString(`{{ 10 | divided_by: 0 }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{},
+ liquid.AuditOptions{},
+ )
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "argument-error" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected diagnostic code \"argument-error\", got: %v", result.Diagnostics)
+ }
+ if found.Severity != liquid.SeverityError {
+ t.Errorf("severity = %q, want %q", found.Severity, liquid.SeverityError)
+ }
+}
+
+func TestRenderAudit_Diagnostic_typeMismatch(t *testing.T) {
+ eng := newAuditEngine()
+ // Comparing a string with an integer — type mismatch.
+ tpl, err := eng.ParseString(`{% if status == 1 %}yes{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"status": "active"},
+ liquid.AuditOptions{},
+ )
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "type-mismatch" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected diagnostic code \"type-mismatch\", got: %v", result.Diagnostics)
+ }
+ if found.Severity != liquid.SeverityWarning {
+ t.Errorf("severity = %q, want %q", found.Severity, liquid.SeverityWarning)
+ }
+}
+
+func TestRenderAudit_Diagnostic_notIterable(t *testing.T) {
+ eng := newAuditEngine()
+ // for over a string — not-iterable.
+ tpl, err := eng.ParseString(`{% for item in status %}{{ item }}{% endfor %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"status": "pending"},
+ liquid.AuditOptions{},
+ )
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "not-iterable" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected diagnostic code \"not-iterable\", got: %v", result.Diagnostics)
+ }
+ if found.Severity != liquid.SeverityWarning {
+ t.Errorf("severity = %q, want %q", found.Severity, liquid.SeverityWarning)
+ }
+}
+
+func TestRenderAudit_Diagnostic_nilDereference(t *testing.T) {
+ eng := newAuditEngine()
+ // customer.address.city where address is nil — nil-dereference.
+ tpl, err := eng.ParseString(`{{ customer.address.city }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, _ := tpl.RenderAudit(
+ liquid.Bindings{"customer": map[string]any{"address": nil}},
+ liquid.AuditOptions{},
+ )
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "nil-dereference" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected diagnostic code \"nil-dereference\", got: %v", result.Diagnostics)
+ }
+ if found.Severity != liquid.SeverityWarning {
+ t.Errorf("severity = %q, want %q", found.Severity, liquid.SeverityWarning)
+ }
+}
+
+func TestRenderAudit_ConditionComparisons_expressionField(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if x >= 10 %}big{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"x": 15},
+ liquid.AuditOptions{TraceConditions: true},
+ )
+ if ae != nil {
+ t.Fatalf("unexpected error: %v", ae)
+ }
+ var condExpr *liquid.Expression
+ for i := range result.Expressions {
+ if result.Expressions[i].Kind == liquid.KindCondition {
+ condExpr = &result.Expressions[i]
+ break
+ }
+ }
+ if condExpr == nil || len(condExpr.Condition.Branches) == 0 {
+ t.Fatal("no condition expression or no branches")
+ }
+ items := condExpr.Condition.Branches[0].Items
+ if len(items) == 0 || items[0].Comparison == nil {
+ t.Fatal("no comparison item in if branch")
+ }
+ expr := items[0].Comparison.Expression
+ if expr == "" {
+ t.Error("ComparisonTrace.Expression should be non-empty for a simple comparison branch")
+ }
+}
+
+func TestRenderAudit_Diagnostic_typeMismatch_hasRange(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% if status == 1 %}yes{% endif %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, _ := tpl.RenderAudit(liquid.Bindings{"status": "active"}, liquid.AuditOptions{})
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "type-mismatch" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatal("expected type-mismatch diagnostic")
+ }
+ if found.Range.Start.Line == 0 {
+ t.Error("type-mismatch diagnostic Range.Start.Line should be non-zero")
+ }
+ if found.Source == "" {
+ t.Error("type-mismatch diagnostic Source should be non-empty")
+ }
+}
+
+func TestRenderAudit_Diagnostic_nilDereference_hasRange(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{{ customer.address.city }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, _ := tpl.RenderAudit(liquid.Bindings{"customer": map[string]any{"address": nil}}, liquid.AuditOptions{})
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "nil-dereference" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatal("expected nil-dereference diagnostic")
+ }
+ if found.Range.Start.Line == 0 {
+ t.Error("nil-dereference diagnostic Range.Start.Line should be non-zero")
+ }
+ if found.Source == "" {
+ t.Error("nil-dereference diagnostic Source should be non-empty")
+ }
+}
+
+func TestRenderAudit_Diagnostic_notIterable_hasRange(t *testing.T) {
+ eng := newAuditEngine()
+ tpl, err := eng.ParseString(`{% for item in order %}{{ item }}{% endfor %}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, _ := tpl.RenderAudit(liquid.Bindings{"order": 42}, liquid.AuditOptions{})
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "not-iterable" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatal("expected not-iterable diagnostic")
+ }
+ if found.Range.Start == found.Range.End {
+ t.Error("not-iterable diagnostic Range should be a span (Start != End)")
+ }
+}
+
+func TestValidate_UndefinedFilter(t *testing.T) {
+ eng := liquid.NewEngine()
+ tpl, err := eng.ParseString(`{{ product.price | no_such_filter }}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, valErr := tpl.Validate()
+ if valErr != nil {
+ t.Fatal(valErr)
+ }
+ var found *liquid.Diagnostic
+ for i := range result.Diagnostics {
+ if result.Diagnostics[i].Code == "undefined-filter" {
+ found = &result.Diagnostics[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("expected undefined-filter diagnostic, got: %v", result.Diagnostics)
+ }
+ if found.Severity != liquid.SeverityError {
+ t.Errorf("severity = %q, want %q", found.Severity, liquid.SeverityError)
+ }
+}
diff --git a/render_audit_variable_test.go b/render_audit_variable_test.go
new file mode 100644
index 00000000..31d7db65
--- /dev/null
+++ b/render_audit_variable_test.go
@@ -0,0 +1,886 @@
+package liquid_test
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+)
+
+// ============================================================================
+// VariableTrace — Name, Parts, Value (V01–V16)
+// ============================================================================
+
+// V01 — simple single-segment variable.
+func TestRenderAudit_Variable_V01_simple(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": "hello"}, liquid.AuditOptions{TraceVariables: true})
+ assertExprCount(t, result, 1)
+ e := result.Expressions[0]
+ if e.Kind != liquid.KindVariable {
+ t.Fatalf("Kind=%q, want %q", e.Kind, liquid.KindVariable)
+ }
+ if e.Variable == nil {
+ t.Fatal("Variable is nil")
+ }
+ if e.Variable.Name != "x" {
+ t.Errorf("Name=%q, want %q", e.Variable.Name, "x")
+ }
+ if len(e.Variable.Parts) != 1 || e.Variable.Parts[0] != "x" {
+ t.Errorf("Parts=%v, want [\"x\"]", e.Variable.Parts)
+ }
+ if e.Variable.Value != "hello" {
+ t.Errorf("Value=%v, want %q", e.Variable.Value, "hello")
+ }
+}
+
+// V02 — dot-access two-level path.
+func TestRenderAudit_Variable_V02_dotAccess(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ customer.name }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"customer": map[string]any{"name": "Alice"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Name != "customer.name" {
+ t.Errorf("Name=%q, want %q", v.Variable.Name, "customer.name")
+ }
+ if len(v.Variable.Parts) != 2 {
+ t.Fatalf("len(Parts)=%d, want 2", len(v.Variable.Parts))
+ }
+ if v.Variable.Parts[0] != "customer" || v.Variable.Parts[1] != "name" {
+ t.Errorf("Parts=%v, want [customer name]", v.Variable.Parts)
+ }
+ if v.Variable.Value != "Alice" {
+ t.Errorf("Value=%v, want Alice", v.Variable.Value)
+ }
+}
+
+// V03 — deep dot-access four-level path.
+func TestRenderAudit_Variable_V03_deepDotAccess(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a.b.c.d }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"a": map[string]any{"b": map[string]any{"c": map[string]any{"d": "deep"}}}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Name != "a.b.c.d" {
+ t.Errorf("Name=%q, want %q", v.Variable.Name, "a.b.c.d")
+ }
+ if len(v.Variable.Parts) != 4 {
+ t.Fatalf("len(Parts)=%d, want 4", len(v.Variable.Parts))
+ }
+ if v.Variable.Value != "deep" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "deep")
+ }
+}
+
+// V04 — array index access via bracket notation.
+func TestRenderAudit_Variable_V04_arrayIndex(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items[0] }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"alpha", "beta"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "alpha" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "alpha")
+ }
+ // Name/Parts may vary by implementation; just verify they are non-empty.
+ if v.Variable.Name == "" {
+ t.Error("Name should be non-empty for bracket access")
+ }
+}
+
+// V05 — string literal in an object expression.
+func TestRenderAudit_Variable_V05_stringLiteral(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ "hello" }}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "hello" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "hello")
+ }
+}
+
+// V06 — integer literal.
+func TestRenderAudit_Variable_V06_intLiteral(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 42 }}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if sprintVal(v.Variable.Value) != "42" {
+ t.Errorf("Value=%v, want 42", v.Variable.Value)
+ }
+}
+
+// V07 — float literal.
+func TestRenderAudit_Variable_V07_floatLiteral(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 3.14 }}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if sprintVal(v.Variable.Value) != "3.14" {
+ t.Errorf("Value=%v, want 3.14", v.Variable.Value)
+ }
+}
+
+// V08 — boolean true literal.
+func TestRenderAudit_Variable_V08_boolTrue(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ true }}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != true {
+ t.Errorf("Value=%v, want true", v.Variable.Value)
+ }
+}
+
+// V09 — boolean false literal.
+func TestRenderAudit_Variable_V09_boolFalse(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ false }}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != false {
+ t.Errorf("Value=%v, want false", v.Variable.Value)
+ }
+}
+
+// V10 — nil literal renders as empty string; value is nil.
+func TestRenderAudit_Variable_V10_nilLiteral(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ nil }}")
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ assertOutput(t, result, "")
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != nil {
+ t.Errorf("Value=%v, want nil", v.Variable.Value)
+ }
+}
+
+// V13 — undefined variable without StrictVariables → Value nil, no error.
+func TestRenderAudit_Variable_V13_undefinedNoStrict(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ ghost }}")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ if ae != nil {
+ t.Fatalf("unexpected AuditError without StrictVariables: %v", ae)
+ }
+ assertNoDiags(t, result)
+ assertOutput(t, result, "")
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression (undefined vars are still traced)")
+ }
+ if v.Variable.Value != nil {
+ t.Errorf("Value=%v, want nil for undefined var", v.Variable.Value)
+ }
+}
+
+// V14 — undefined variable WITH StrictVariables → Error on expression + Diagnostic.
+func TestRenderAudit_Variable_V14_undefinedWithStrict(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ ghost }}")
+ result, ae := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true}, liquid.WithStrictVariables())
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ if ae == nil {
+ t.Fatal("expected AuditError for undefined variable with StrictVariables")
+ }
+ d := firstDiag(result.Diagnostics, "undefined-variable")
+ if d == nil {
+ t.Fatal("expected undefined-variable diagnostic")
+ }
+ // Expression should also carry the error reference.
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("variable expression should still appear even when it errored")
+ }
+ if v.Error == nil {
+ t.Error("Expression.Error should be non-nil when variable caused an error")
+ }
+}
+
+// V15 — multiple variables in sequence; all traced.
+func TestRenderAudit_Variable_V15_multipleVars(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a }}{{ b }}{{ c }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"a": 1, "b": 2, "c": 3},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ assertExprCount(t, result, 3)
+ for i, e := range result.Expressions {
+ if e.Kind != liquid.KindVariable {
+ t.Errorf("Expressions[%d].Kind=%q, want variable", i, e.Kind)
+ }
+ }
+ names := []string{
+ result.Expressions[0].Variable.Name,
+ result.Expressions[1].Variable.Name,
+ result.Expressions[2].Variable.Name,
+ }
+ if names[0] != "a" || names[1] != "b" || names[2] != "c" {
+ t.Errorf("Names=%v, want [a b c]", names)
+ }
+}
+
+// V16 — bracket string-key access on a map.
+func TestRenderAudit_Variable_V16_bracketStringKey(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ hash["key"] }}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"hash": map[string]any{"key": "val"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "val" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "val")
+ }
+}
+
+// ============================================================================
+// VariableTrace — Filter Pipeline (VP01–VP24)
+// ============================================================================
+
+// VP01 — no filters → Pipeline is empty (not nil).
+func TestRenderAudit_Variable_VP01_noPipeline(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name }}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "alice"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 0 {
+ t.Errorf("Pipeline should be empty when no filters, got %d steps", len(v.Variable.Pipeline))
+ }
+}
+
+// VP02 — single filter, no args (upcase).
+func TestRenderAudit_Variable_VP02_singleFilterNoArgs(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name | upcase }}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "alice"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Filter != "upcase" {
+ t.Errorf("Filter=%q, want %q", step.Filter, "upcase")
+ }
+ if len(step.Args) != 0 {
+ t.Errorf("Args=%v, want []", step.Args)
+ }
+ if step.Input != "alice" {
+ t.Errorf("Input=%v, want %q", step.Input, "alice")
+ }
+ if step.Output != "ALICE" {
+ t.Errorf("Output=%v, want %q", step.Output, "ALICE")
+ }
+}
+
+// VP03 — single filter with one integer arg (truncate: 5).
+func TestRenderAudit_Variable_VP03_singleFilterOneArg(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ msg | truncate: 8 }}`)
+ result := auditOK(t, tpl, liquid.Bindings{"msg": "hello world"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Filter != "truncate" {
+ t.Errorf("Filter=%q, want truncate", step.Filter)
+ }
+ if len(step.Args) == 0 {
+ t.Error("Args should not be empty for truncate: 8")
+ }
+ if sprintVal(step.Args[0]) != "8" {
+ t.Errorf("Args[0]=%v, want 8", step.Args[0])
+ }
+}
+
+// VP04 — single filter with two args (truncate: 10, "...").
+func TestRenderAudit_Variable_VP04_singleFilterTwoArgs(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ msg | truncate: 10, "~" }}`)
+ result := auditOK(t, tpl, liquid.Bindings{"msg": "hello, world!"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Filter != "truncate" {
+ t.Errorf("Filter=%q, want truncate", step.Filter)
+ }
+ if len(step.Args) < 2 {
+ t.Fatalf("Args len=%d, want >= 2", len(step.Args))
+ }
+ if step.Args[1] != "~" {
+ t.Errorf("Args[1]=%v, want %q", step.Args[1], "~")
+ }
+}
+
+// VP05 — chain of two filters: Output[0] == Input[1].
+func TestRenderAudit_Variable_VP05_twoFilterChain(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name | upcase | truncate: 3 }}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "alice"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 2 {
+ t.Fatalf("Pipeline len=%d, want 2", len(v.Variable.Pipeline))
+ }
+ step0, step1 := v.Variable.Pipeline[0], v.Variable.Pipeline[1]
+ if step0.Filter != "upcase" {
+ t.Errorf("Pipeline[0].Filter=%q, want upcase", step0.Filter)
+ }
+ if step1.Filter != "truncate" {
+ t.Errorf("Pipeline[1].Filter=%q, want truncate", step1.Filter)
+ }
+ // Output of step0 must equal Input of step1.
+ if step0.Output != step1.Input {
+ t.Errorf("step0.Output=%v != step1.Input=%v (chain broken)", step0.Output, step1.Input)
+ }
+ // Final value on the trace should be step1.Output.
+ if v.Variable.Value != step1.Output {
+ t.Errorf("Variable.Value=%v != Pipeline[-1].Output=%v", v.Variable.Value, step1.Output)
+ }
+}
+
+// VP06 — chain of three filters: downcase | prepend | upcase.
+func TestRenderAudit_Variable_VP06_threeFilterChain(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ name | downcase | prepend: "hi " | upcase }}`)
+ result := auditOK(t, tpl, liquid.Bindings{"name": "ALICE"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 3 {
+ t.Fatalf("Pipeline len=%d, want 3", len(v.Variable.Pipeline))
+ }
+ // Each step's Output should equal the next step's Input.
+ for i := range 2 {
+ if v.Variable.Pipeline[i].Output != v.Variable.Pipeline[i+1].Input {
+ t.Errorf("pipeline chain broken between step %d and %d", i, i+1)
+ }
+ }
+ // Final value.
+ expected := "HI ALICE"
+ if v.Variable.Value != expected {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, expected)
+ }
+}
+
+// VP07 — filter `default` with nil value.
+func TestRenderAudit_Variable_VP07_defaultFilter(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ missing | default: "fallback" }}`)
+ result := auditOK(t, tpl, liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Filter != "default" {
+ t.Errorf("Filter=%q, want default", step.Filter)
+ }
+ if step.Output != "fallback" {
+ t.Errorf("Output=%v, want fallback", step.Output)
+ }
+ if v.Variable.Value != "fallback" {
+ t.Errorf("Value=%v, want fallback", v.Variable.Value)
+ }
+}
+
+// VP08 — filter `split` returns a slice.
+func TestRenderAudit_Variable_VP08_splitFilter(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ csv | split: "," }}`)
+ result := auditOK(t, tpl, liquid.Bindings{"csv": "a,b,c"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Input != "a,b,c" {
+ t.Errorf("Input=%v, want %q", step.Input, "a,b,c")
+ }
+ // Output should be a slice of strings.
+ out, ok := step.Output.([]string)
+ if !ok {
+ t.Errorf("Output type=%T, want []string", step.Output)
+ } else if len(out) != 3 {
+ t.Errorf("output slice len=%d, want 3", len(out))
+ }
+}
+
+// VP09 — filter `size` on a string returns its length.
+func TestRenderAudit_Variable_VP09_sizeOnString(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ word | size }}")
+ result := auditOK(t, tpl, liquid.Bindings{"word": "hello"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if sprintVal(v.Variable.Value) != "5" {
+ t.Errorf("Value=%v, want 5", v.Variable.Value)
+ }
+}
+
+// VP10 — filter `size` on an array returns its length.
+func TestRenderAudit_Variable_VP10_sizeOnArray(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items | size }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []int{1, 2, 3, 4}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if sprintVal(v.Variable.Value) != "4" {
+ t.Errorf("Value=%v, want 4", v.Variable.Value)
+ }
+}
+
+// VP11 — filter `times` on a number.
+func TestRenderAudit_Variable_VP11_timesFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ price | times: 2 }}")
+ result := auditOK(t, tpl, liquid.Bindings{"price": 10}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Filter != "times" {
+ t.Errorf("Filter=%q, want times", step.Filter)
+ }
+ if sprintVal(step.Output) != "20" {
+ t.Errorf("Output=%v, want 20", step.Output)
+ }
+}
+
+// VP12 — filter `round` converts float to int.
+func TestRenderAudit_Variable_VP12_roundFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ price | round }}")
+ result := auditOK(t, tpl, liquid.Bindings{"price": 3.7}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if sprintVal(v.Variable.Value) != "4" {
+ t.Errorf("Value=%v, want 4", v.Variable.Value)
+ }
+}
+
+// VP13 — filter `join` on an array produces a string.
+func TestRenderAudit_Variable_VP13_joinFilter(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ tags | join: ", " }}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{"tags": []string{"go", "liquid", "test"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "go, liquid, test" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "go, liquid, test")
+ }
+}
+
+// VP15 — filter `first` on an array.
+func TestRenderAudit_Variable_VP15_firstFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items | first }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "a" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "a")
+ }
+}
+
+// VP16 — filter `last` on an array.
+func TestRenderAudit_Variable_VP16_lastFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items | last }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Variable.Value != "c" {
+ t.Errorf("Value=%v, want %q", v.Variable.Value, "c")
+ }
+}
+
+// VP17 — filter `map` returns a slice of extracted values.
+func TestRenderAudit_Variable_VP17_mapFilter(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ products | map: "name" }}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{
+ "products": []map[string]any{
+ {"name": "Widget", "price": 10},
+ {"name": "Gadget", "price": 20},
+ },
+ },
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+ step := v.Variable.Pipeline[0]
+ if step.Filter != "map" {
+ t.Errorf("Filter=%q, want map", step.Filter)
+ }
+ // Output should be a slice.
+ switch step.Output.(type) {
+ case []any, []string:
+ // acceptable
+ default:
+ t.Errorf("Output type=%T, want slice", step.Output)
+ }
+}
+
+// VP18 — filter `where` on an array.
+func TestRenderAudit_Variable_VP18_whereFilter(t *testing.T) {
+ tpl := mustParseAudit(t, `{{ products | where: "active", true }}`)
+ result := auditOK(t, tpl,
+ liquid.Bindings{
+ "products": []map[string]any{
+ {"name": "A", "active": true},
+ {"name": "B", "active": false},
+ },
+ },
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 {
+ t.Fatalf("Pipeline len=%d, want 1", len(v.Variable.Pipeline))
+ }
+}
+
+// VP19 — filter `sort` on a numeric array.
+func TestRenderAudit_Variable_VP19_sortFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ nums | sort }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"nums": []int{3, 1, 2}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 || v.Variable.Pipeline[0].Filter != "sort" {
+ t.Error("expected sort filter step")
+ }
+}
+
+// VP20 — filter `reverse` on an array.
+func TestRenderAudit_Variable_VP20_reverseFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items | reverse }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "c"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 || v.Variable.Pipeline[0].Filter != "reverse" {
+ t.Error("expected reverse filter step")
+ }
+}
+
+// VP21 — filter `compact` removes nil values.
+func TestRenderAudit_Variable_VP21_compactFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items | compact }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []any{"a", nil, "b", nil, "c"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 || v.Variable.Pipeline[0].Filter != "compact" {
+ t.Error("expected compact filter step")
+ }
+}
+
+// VP22 — filter `uniq` removes duplicates.
+func TestRenderAudit_Variable_VP22_uniqFilter(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ items | uniq }}")
+ result := auditOK(t, tpl,
+ liquid.Bindings{"items": []string{"a", "b", "a", "c"}},
+ liquid.AuditOptions{TraceVariables: true},
+ )
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil || v.Variable == nil {
+ t.Fatal("no variable expression")
+ }
+ if len(v.Variable.Pipeline) != 1 || v.Variable.Pipeline[0].Filter != "uniq" {
+ t.Error("expected uniq filter step")
+ }
+}
+
+// VP23 — undefined filter with LaxFilters → no error, value passes through.
+func TestRenderAudit_Variable_VP23_laxFilters(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name | no_such_filter }}")
+ result, ae := tpl.RenderAudit(
+ liquid.Bindings{"name": "alice"},
+ liquid.AuditOptions{TraceVariables: true},
+ liquid.WithLaxFilters(),
+ )
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ if ae != nil {
+ t.Fatalf("unexpected AuditError with LaxFilters: %v", ae)
+ }
+}
+
+// VP24 — filter that causes an error (divided_by: 0) → Error on expression + Diagnostic.
+func TestRenderAudit_Variable_VP24_filterError(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ 10 | divided_by: 0 }}")
+ result, _ := tpl.RenderAudit(liquid.Bindings{}, liquid.AuditOptions{TraceVariables: true})
+ if result == nil {
+ t.Fatal("result is nil")
+ }
+ d := firstDiag(result.Diagnostics, "argument-error")
+ if d == nil {
+ t.Fatal("expected argument-error diagnostic")
+ }
+}
+
+// ============================================================================
+// VariableTrace — Source and Range (VR01–VR07)
+// ============================================================================
+
+// VR01 — Source includes the {{ }} delimiters.
+func TestRenderAudit_Variable_VR01_sourceIncludesDelimiters(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name }}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "x"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Source != "{{ name }}" {
+ t.Errorf("Source=%q, want %q", v.Source, "{{ name }}")
+ }
+}
+
+// VR02 — Range.Start.Line = 1 when expression is on first line.
+func TestRenderAudit_Variable_VR02_lineOne(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Line != 1 {
+ t.Errorf("Range.Start.Line=%d, want 1", v.Range.Start.Line)
+ }
+}
+
+// VR03 — Range.Start.Line = 3 when expression is on third line.
+func TestRenderAudit_Variable_VR03_lineThree(t *testing.T) {
+ tpl := mustParseAudit(t, "line1\nline2\n{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Line != 3 {
+ t.Errorf("Range.Start.Line=%d, want 3", v.Range.Start.Line)
+ }
+}
+
+// VR04 — Range.Start.Column >= 1 (never zero).
+func TestRenderAudit_Variable_VR04_columnAtLeastOne(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Range.Start.Column < 1 {
+ t.Errorf("Range.Start.Column=%d, want >= 1", v.Range.Start.Column)
+ }
+}
+
+// VR05 — Range.End is after Range.Start (non-zero span).
+func TestRenderAudit_Variable_VR05_rangeIsSpan(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ name }}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "x"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ assertRangeSpan(t, v.Range, "variable {{ name }}")
+}
+
+// VR06 — Range.End.Column = Start.Column + len("{{ name }}") for single-line expression at col 1.
+func TestRenderAudit_Variable_VR06_endColumnPrecise(t *testing.T) {
+ // "{{ name }}" is 10 chars; at col 1, End.Column should be 11 (exclusive).
+ tpl := mustParseAudit(t, "{{ name }}")
+ result := auditOK(t, tpl, liquid.Bindings{"name": "x"}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ src := "{{ name }}"
+ wantEndCol := v.Range.Start.Column + len(src)
+ if v.Range.End.Column != wantEndCol {
+ t.Errorf("Range.End.Column=%d, want %d (Start.Column + len(source))", v.Range.End.Column, wantEndCol)
+ }
+}
+
+// VR07 — Multiple expressions in same template have non-overlapping Ranges.
+func TestRenderAudit_Variable_VR07_noOverlappingRanges(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ a }} {{ b }}")
+ result := auditOK(t, tpl, liquid.Bindings{"a": 1, "b": 2}, liquid.AuditOptions{TraceVariables: true})
+ if len(result.Expressions) < 2 {
+ t.Fatalf("expected 2 expressions, got %d", len(result.Expressions))
+ }
+ r0 := result.Expressions[0].Range
+ r1 := result.Expressions[1].Range
+ // r1.Start must be after r0.End
+ if r1.Start.Line < r0.End.Line ||
+ (r1.Start.Line == r0.End.Line && r1.Start.Column < r0.End.Column) {
+ t.Errorf("ranges overlap: r0=[%v→%v] r1=[%v→%v]", r0.Start, r0.End, r1.Start, r1.End)
+ }
+}
+
+// ============================================================================
+// VariableTrace — Depth (VD01–VD06)
+// ============================================================================
+
+// VD01 — top-level variable has Depth = 0.
+func TestRenderAudit_Variable_VD01_depthZeroTopLevel(t *testing.T) {
+ tpl := mustParseAudit(t, "{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Depth != 0 {
+ t.Errorf("Depth=%d, want 0 for top-level variable", v.Depth)
+ }
+}
+
+// VD02 — variable inside one {% if %} block has Depth = 1.
+func TestRenderAudit_Variable_VD02_depthOneInsideIf(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if true %}{{ x }}{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Depth != 1 {
+ t.Errorf("Depth=%d, want 1 (inside if)", v.Depth)
+ }
+}
+
+// VD03 — variable inside one {% for %} block has Depth = 1.
+func TestRenderAudit_Variable_VD03_depthOneInsideFor(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{{ item }}{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{"items": []int{1}}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Depth != 1 {
+ t.Errorf("Depth=%d, want 1 (inside for)", v.Depth)
+ }
+}
+
+// VD04 — variable inside nested {% if %}{% if %} has Depth = 2.
+func TestRenderAudit_Variable_VD04_depthTwoNestedIf(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if true %}{% if true %}{{ x }}{% endif %}{% endif %}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Depth != 2 {
+ t.Errorf("Depth=%d, want 2 (nested if×if)", v.Depth)
+ }
+}
+
+// VD05 — variable inside {% for %}{% if %} has Depth = 2.
+func TestRenderAudit_Variable_VD05_depthTwoForIf(t *testing.T) {
+ tpl := mustParseAudit(t, "{% for item in items %}{% if true %}{{ item }}{% endif %}{% endfor %}")
+ result := auditOK(t, tpl, liquid.Bindings{"items": []int{1}}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Depth != 2 {
+ t.Errorf("Depth=%d, want 2 (for > if)", v.Depth)
+ }
+}
+
+// VD06 — after exiting a block, subsequent top-level variable is Depth = 0.
+func TestRenderAudit_Variable_VD06_depthResetsAfterBlock(t *testing.T) {
+ tpl := mustParseAudit(t, "{% if true %}{% endif %}{{ x }}")
+ result := auditOK(t, tpl, liquid.Bindings{"x": 1}, liquid.AuditOptions{TraceVariables: true})
+ v := firstExpr(result.Expressions, liquid.KindVariable)
+ if v == nil {
+ t.Fatal("no variable expression")
+ }
+ if v.Depth != 0 {
+ t.Errorf("Depth=%d, want 0 (after block exits)", v.Depth)
+ }
+}
diff --git a/s10_error_handling_e2e_test.go b/s10_error_handling_e2e_test.go
new file mode 100644
index 00000000..53333334
--- /dev/null
+++ b/s10_error_handling_e2e_test.go
@@ -0,0 +1,1130 @@
+package liquid_test
+
+// s10_error_handling_e2e_test.go — Intensive E2E tests for Section 10: Tratamento de Erros
+//
+// Coverage matrix (regression guard: prevents silent behaviour changes):
+//
+// A. ParseError / SyntaxError
+// A1 — basic "Liquid syntax error" prefix on all parse-time failures
+// A2 — SyntaxError type alias: errors.As works with both *ParseError and *SyntaxError
+// A3 — line number on single-line template
+// A4 — line number on multi-line template (error on line N ≠ 1)
+// A5 — line number correct inside nested blocks
+// A6 — line number correct when whitespace-trim markers ({%- -%}) are used
+// A7 — Path() and LineNumber() on ParseError
+// A8 — Message() strips prefix and location info
+// A9 — MarkupContext() returns exact source text of the failing token
+// A10 — unknown tag → ParseError (not a runtime/render error)
+// A11 — unclosed block → ParseError
+// A12 — invalid operator (=!) → ParseError
+//
+// B. RenderError
+// B1 — "Liquid error" prefix (NOT "Liquid syntax error")
+// B2 — ZeroDivision wrapped in *render.RenderError
+// B3 — plain filter error wrapped in *render.RenderError
+// B4 — plain tag error wrapped in *render.RenderError
+// B5 — line number correct on first line and on line N
+// B6 — Message() strips "Liquid error" prefix and location
+// B7 — MarkupContext() carries the failing {{ expr }} source text
+//
+// C. ZeroDivisionError
+// C1 — divided_by: 0 → *filters.ZeroDivisionError findable via errors.As
+// C2 — modulo: 0 → *filters.ZeroDivisionError findable via errors.As
+// C3 — ZeroDivisionError sits below RenderError in the chain
+// C4 — divided_by / modulo with non-zero → no error
+// C5 — ZeroDivisionError message content
+//
+// D. ArgumentError / ContextError (typed leaf errors)
+// D1 — filter returning *render.ArgumentError → detectable via errors.As
+// D2 — tag returning *render.ArgumentError → detectable via errors.As
+// D3 — tag returning *render.ContextError → detectable via errors.As
+// D4 — ArgumentError message carried through chain
+// D5 — ContextError message carried through chain
+// D6 — error from filter has "Liquid error" prefix in full string
+//
+// E. UndefinedVariableError
+// E1 — default mode: undefined variable → empty string, no error
+// E2 — StrictVariables(): undefined var → *render.UndefinedVariableError
+// E3 — Name field set to root variable name
+// E4 — line number and markup context set correctly
+// E5 — per-render WithStrictVariables() same as engine-level
+// E6 — errors.As chain: UndefinedVariableError findable
+// E7 — defined variable with StrictVariables: no error
+// E8 — dotted access: root name preserved (e.g. user.name → Name="user")
+//
+// F. WithErrorHandler (exception_renderer)
+// F1 — handler output replaces the failing node text
+// F2 — rendering continues after the failing node
+// F3 — multiple errors handled; output assembled in order
+// F4 — handler receives the error (errors.As works inside handler)
+// F5 — parse errors are NOT caught by the render handler
+// F6 — non-erroring nodes render correctly alongside failing nodes
+//
+// G. markup_context metadata (end-to-end)
+// G1 — Error() shows markup context ({{ expr }}) when no path set
+// G2 — Error() shows path NOT markup context when path is set
+// G3 — nested render: inner markup context preserved over outer block source
+// G4 — MarkupContext() is empty when no locatable information is available
+//
+// H. Error chain walking (errors.As through full chain)
+// H1 — ZeroDivisionError walkable without knowing intermediate types
+// H2 — ArgumentError walkable from top-level error
+// H3 — RenderError always present in chain for render-time failures
+// H4 — ParseError always present in chain for parse-time failures
+//
+// I. Prefix invariants (regression guard)
+// I1 — every parse-time error starts with "Liquid syntax error"
+// I2 — every render-time error starts with "Liquid error" (never "Liquid syntax error")
+// I3 — render error with line N includes "(line N)" in string
+// I4 — parse error with line N includes "(line N)" in string
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/osteele/liquid"
+ "github.com/osteele/liquid/filters"
+ "github.com/osteele/liquid/parser"
+ "github.com/osteele/liquid/render"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+func s10eng(t *testing.T) *liquid.Engine {
+ t.Helper()
+ return liquid.NewEngine()
+}
+
+func s10mustParse(t *testing.T, eng *liquid.Engine, src string) *liquid.Template {
+ t.Helper()
+ tpl, err := eng.ParseString(src)
+ require.NoError(t, err, "unexpected parse error for %q", src)
+ return tpl
+}
+
+func s10parseErr(t *testing.T, src string) error {
+ t.Helper()
+ _, err := s10eng(t).ParseString(src)
+ require.Error(t, err, "expected a parse error for %q", src)
+ return err
+}
+
+func s10renderErr(t *testing.T, eng *liquid.Engine, src string, binds map[string]any) error {
+ t.Helper()
+ _, err := eng.ParseAndRenderString(src, binds)
+ require.Error(t, err, "expected a render error for %q", src)
+ return err
+}
+
+func s10render(t *testing.T, eng *liquid.Engine, src string, binds map[string]any) string {
+ t.Helper()
+ out, err := eng.ParseAndRenderString(src, binds)
+ require.NoError(t, err, "unexpected error for %q", src)
+ return out
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// A. ParseError / SyntaxError
+// ═════════════════════════════════════════════════════════════════════════════
+
+// A1 — ParseError carries "Liquid syntax error" prefix for all parse failures.
+func TestS10A1_ParseError_Prefix_UnclosedFor(t *testing.T) {
+ err := s10parseErr(t, `{% for x in arr %}`)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "got: %q", err.Error())
+}
+
+func TestS10A1_ParseError_Prefix_UnclosedIf(t *testing.T) {
+ err := s10parseErr(t, `{% if cond %}`)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "got: %q", err.Error())
+}
+
+func TestS10A1_ParseError_Prefix_UnclosedCapture(t *testing.T) {
+ err := s10parseErr(t, `{% capture x %}`)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "got: %q", err.Error())
+}
+
+func TestS10A1_ParseError_Prefix_UnclosedUnless(t *testing.T) {
+ err := s10parseErr(t, `{% unless cond %}`)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "got: %q", err.Error())
+}
+
+func TestS10A1_ParseError_Prefix_UnknownTag(t *testing.T) {
+ err := s10parseErr(t, `{% totallynotthere %}`)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "got: %q", err.Error())
+}
+
+// A2 — SyntaxError alias: errors.As works with both *ParseError and *SyntaxError.
+func TestS10A2_SyntaxError_Alias_ParseError(t *testing.T) {
+ err := s10parseErr(t, `{% unclosed %}`)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe), "errors.As(*ParseError) failed, got %T", err)
+}
+
+func TestS10A2_SyntaxError_Alias_SyntaxError(t *testing.T) {
+ err := s10parseErr(t, `{% unclosed %}`)
+ var se *parser.SyntaxError
+ require.True(t, errors.As(err, &se), "errors.As(*SyntaxError) failed, got %T", err)
+}
+
+func TestS10A2_SyntaxError_Alias_SamePointer(t *testing.T) {
+ err := s10parseErr(t, `{% unclosed %}`)
+ var pe *parser.ParseError
+ var se *parser.SyntaxError
+ require.True(t, errors.As(err, &pe))
+ require.True(t, errors.As(err, &se))
+ // SyntaxError = ParseError (type alias) — same object
+ require.Equal(t, pe, se)
+}
+
+// A3 — Line number is 1 for single-line templates.
+func TestS10A3_ParseError_LineNumber_SingleLine(t *testing.T) {
+ err := s10parseErr(t, `{% unknowntag_s10a3 %}`)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe))
+ assert.Equal(t, 1, pe.LineNumber())
+ assert.Contains(t, pe.Error(), "line 1")
+}
+
+// A4 — Line number correct when error is on line N > 1.
+func TestS10A4_ParseError_LineNumber_Line2(t *testing.T) {
+ src := "good line 1\n{% unknowntag_s10a4 %}"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "line 2")
+}
+
+func TestS10A4_ParseError_LineNumber_Line3(t *testing.T) {
+ src := "foobar\n\n{% unknowntag_s10a4_line3 %}"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "line 3")
+}
+
+func TestS10A4_ParseError_LineNumber_Line5(t *testing.T) {
+ src := "l1\nl2\nl3\nl4\n{% unknowntag_s10a4_line5 %}"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "line 5")
+}
+
+// A5 — Line number correct for errors inside nested blocks.
+func TestS10A5_ParseError_LineNumber_NestedBlock(t *testing.T) {
+ // Unknown tag inside {% if %} — error must report line 4, not line 1.
+ src := "foobar\n\n{% if 1 != 2 %}\n {% unknowntag_nested %}\n{% endif %}\n\nbla"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "line 4",
+ "nested error at line 4, full error: %q", err.Error())
+}
+
+func TestS10A5_ParseError_LineNumber_NestedFor(t *testing.T) {
+ src := "before\n{% for i in arr %}\n {% nosuchfoo %}\n{% endfor %}"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "line 3")
+}
+
+// A6 — Whitespace-trim markers do not shift line numbers.
+func TestS10A6_ParseError_WhitespaceTrim_LineNumberUnchanged(t *testing.T) {
+ // Without trim markers: line 3
+ src1 := "foobar\n\n{% unknowntag_s10a6 %}\n\nbla"
+ err1 := s10parseErr(t, src1)
+ assert.Contains(t, err1.Error(), "line 3", "without trim markers: %q", err1.Error())
+
+ // With trim markers: still line 3
+ src2 := "foobar\n\n{%- unknowntag_s10a6 -%}\n\nbla"
+ err2 := s10parseErr(t, src2)
+ assert.Contains(t, err2.Error(), "line 3",
+ "trim markers must not shift line number: %q", err2.Error())
+}
+
+func TestS10A6_ParseError_WhitespaceTrim_MultipleLines(t *testing.T) {
+ src := "{%- assign x = 1 -%}\n{%- assign y = 2 -%}\n{%- unknowntag_multiline -%}"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "line 3")
+}
+
+// A7 — Path() and LineNumber() accessible on ParseError.
+func TestS10A7_ParseError_Path_FromToken(t *testing.T) {
+ tok := parser.Token{
+ SourceLoc: parser.SourceLoc{Pathname: "theme/product.html", LineNo: 12},
+ Source: `{% badtag %}`,
+ }
+ err := parser.Errorf(&tok, "unknown tag 'badtag'")
+ assert.Equal(t, "theme/product.html", err.Path())
+ assert.Equal(t, 12, err.LineNumber())
+ assert.Contains(t, err.Error(), "theme/product.html")
+ assert.Contains(t, err.Error(), "line 12")
+}
+
+func TestS10A7_ParseError_NoPath_EmptyString(t *testing.T) {
+ tok := parser.Token{
+ SourceLoc: parser.SourceLoc{LineNo: 3},
+ Source: `{% badtag %}`,
+ }
+ err := parser.Errorf(&tok, "unknown tag")
+ assert.Equal(t, "", err.Path())
+}
+
+// A8 — Message() strips "Liquid syntax error" prefix and "(line N)" location.
+func TestS10A8_ParseError_Message_NoPrefix(t *testing.T) {
+ err := s10parseErr(t, `{% for a in b %}`)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe))
+ msg := pe.Message()
+ assert.NotEmpty(t, msg, "Message() must not be empty")
+ assert.NotContains(t, msg, "Liquid syntax error")
+ assert.NotContains(t, msg, "Liquid error")
+ assert.NotContains(t, msg, "(line ")
+}
+
+func TestS10A8_ParseError_Message_NoLineInfo(t *testing.T) {
+ src := "l1\nl2\n{% for a in b %}" // error on line 3
+ err := s10parseErr(t, src)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe))
+ // Full Error() has "line 3", Message() must not
+ assert.Contains(t, pe.Error(), "line 3")
+ assert.NotContains(t, pe.Message(), "line 3")
+}
+
+// A9 — MarkupContext() returns exact source text of the failing token.
+func TestS10A9_ParseError_MarkupContext_SourceText(t *testing.T) {
+ tok := parser.Token{
+ SourceLoc: parser.SourceLoc{LineNo: 1},
+ Source: `{% bad_tag with_args %}`,
+ }
+ err := parser.Errorf(&tok, "unknown tag")
+ assert.Equal(t, `{% bad_tag with_args %}`, err.MarkupContext())
+}
+
+func TestS10A9_ParseError_MarkupContext_InErrorString_WhenNoPath(t *testing.T) {
+ tok := parser.Token{
+ SourceLoc: parser.SourceLoc{LineNo: 1},
+ Source: `{% some_special_tag %}`,
+ }
+ err := parser.Errorf(&tok, "not found")
+ // No pathname → markup context appears in Error() string
+ assert.Contains(t, err.Error(), `{% some_special_tag %}`)
+}
+
+func TestS10A9_ParseError_MarkupContext_HiddenWhenPathSet(t *testing.T) {
+ tok := parser.Token{
+ SourceLoc: parser.SourceLoc{Pathname: "index.html", LineNo: 1},
+ Source: `{% my_tag %}`,
+ }
+ err := parser.Errorf(&tok, "unknown tag")
+ // With pathname, the path appears instead of the raw markup context
+ assert.Contains(t, err.Error(), "index.html")
+ assert.NotContains(t, err.Error(), `{% my_tag %}`)
+}
+
+// A10 — Unknown tag produces a *parser.ParseError.
+func TestS10A10_UnknownTag_IsParseError(t *testing.T) {
+ err := s10parseErr(t, `{% totally_unknown_tag_xyz %}`)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe), "got %T: %v", err, err)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"))
+}
+
+func TestS10A10_UnknownTag_NotARenderError(t *testing.T) {
+ err := s10parseErr(t, `{% totally_unknown_tag_abc %}`)
+ var re *render.RenderError
+ // Must NOT be a RenderError — parse errors are not render errors
+ assert.False(t, errors.As(err, &re),
+ "unknown tag should be a parse error, not a render error")
+}
+
+// A11 — Unclosed block tag produces a ParseError with appropriate message.
+func TestS10A11_UnclosedBlock_For(t *testing.T) {
+ err := s10parseErr(t, `{% for a in b %} ... `)
+ assert.Contains(t, err.Error(), "Liquid syntax error")
+ assert.Contains(t, err.Error(), "for")
+}
+
+func TestS10A11_UnclosedBlock_If(t *testing.T) {
+ err := s10parseErr(t, `{% if x %}`)
+ assert.Contains(t, err.Error(), "Liquid syntax error")
+}
+
+func TestS10A11_UnclosedBlock_TableRow(t *testing.T) {
+ err := s10parseErr(t, `{% tablerow i in arr %}cell`)
+ assert.Contains(t, err.Error(), "Liquid syntax error")
+}
+
+// A12 — Invalid operator (=!) in expression causes a ParseError.
+func TestS10A12_InvalidOperator_IsParseError(t *testing.T) {
+ err := s10parseErr(t, `{% if 1 =! 2 %}yes{% endif %}`)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe), "=! must cause a ParseError, got %T: %v", err, err)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid syntax error"))
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// B. RenderError
+// ═════════════════════════════════════════════════════════════════════════════
+
+// B1 — RenderError carries "Liquid error" prefix, never "Liquid syntax error".
+func TestS10B1_RenderError_Prefix_ZeroDivision(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 10 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid error"),
+ "render error must start with 'Liquid error', got: %q", err.Error())
+ assert.False(t, strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "render error must NOT start with 'Liquid syntax error', got: %q", err.Error())
+}
+
+func TestS10B1_RenderError_Prefix_CustomFilter(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("err_filter_b1", func(v any) (any, error) {
+ return nil, errors.New("deliberate failure")
+ })
+ err := s10renderErr(t, eng, `{{ "x" | err_filter_b1 }}`, nil)
+ assert.True(t, strings.HasPrefix(err.Error(), "Liquid error"),
+ "got: %q", err.Error())
+}
+
+// B2 — ZeroDivision is wrapped in *render.RenderError.
+func TestS10B2_RenderError_ZeroDivision_WrappedType(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 1 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re), "ZeroDivision must be wrapped in *render.RenderError, got %T", err)
+}
+
+// B3 — Plain filter error wrapped in *render.RenderError.
+func TestS10B3_RenderError_FilterPlainError(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("plain_err_b3", func(v any) (any, error) {
+ return nil, errors.New("plain error from filter")
+ })
+ err := s10renderErr(t, eng, `{{ "x" | plain_err_b3 }}`, nil)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re), "plain filter error must be *render.RenderError, got %T", err)
+ assert.Contains(t, err.Error(), "plain error from filter")
+}
+
+// B4 — Plain tag error wrapped in *render.RenderError.
+func TestS10B4_RenderError_TagPlainError(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterTag("plain_err_b4", func(c render.Context) (string, error) {
+ return "", errors.New("plain error from tag")
+ })
+ err := s10renderErr(t, eng, `{% plain_err_b4 %}`, nil)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re), "plain tag error must be *render.RenderError, got %T", err)
+ assert.Contains(t, err.Error(), "plain error from tag")
+}
+
+// B5 — Line number correct in RenderError.
+func TestS10B5_RenderError_LineNumber_Line1(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 5 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "line 1")
+}
+
+func TestS10B5_RenderError_LineNumber_LineN(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, "line1\nline2\n{{ 5 | divided_by: 0 }}")
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "line 3")
+}
+
+// B6 — Message() strips "Liquid error" prefix and location info.
+func TestS10B6_RenderError_Message_NoPrefix(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 10 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re))
+ msg := re.Message()
+ assert.NotEmpty(t, msg)
+ assert.NotContains(t, msg, "Liquid error")
+ assert.NotContains(t, msg, "(line ")
+}
+
+// B7 — MarkupContext() carries source text of the failing {{ expr }}.
+func TestS10B7_RenderError_MarkupContext_ExprSource(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ product.price | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re))
+ // MarkupContext must contain the expression source
+ assert.Contains(t, re.MarkupContext(), "product.price")
+ // And the full error string shows the markup context (no path set)
+ assert.Contains(t, err.Error(), "product.price")
+}
+
+func TestS10B7_RenderError_MarkupContext_TagSource(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterTag("err_tag_b7", func(c render.Context) (string, error) {
+ return "", errors.New("b7 tag error")
+ })
+ err := s10renderErr(t, eng, `{% err_tag_b7 %}`, nil)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re))
+ assert.Contains(t, re.MarkupContext(), "err_tag_b7")
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// C. ZeroDivisionError
+// ═════════════════════════════════════════════════════════════════════════════
+
+// C1 — divided_by: 0 produces *filters.ZeroDivisionError findable via errors.As.
+func TestS10C1_ZeroDivisionError_DividedBy_ErrorsAs(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 10 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var zde *filters.ZeroDivisionError
+ require.True(t, errors.As(err, &zde), "divided_by: 0 must yield *filters.ZeroDivisionError, got %T", err)
+}
+
+// C2 — modulo: 0 produces *filters.ZeroDivisionError findable via errors.As.
+func TestS10C2_ZeroDivisionError_Modulo_ErrorsAs(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 10 | modulo: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var zde *filters.ZeroDivisionError
+ require.True(t, errors.As(err, &zde), "modulo: 0 must yield *filters.ZeroDivisionError, got %T", err)
+}
+
+// C3 — ZeroDivisionError sits below *render.RenderError in the chain.
+func TestS10C3_ZeroDivisionError_BelowRenderError(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 7 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var re *render.RenderError
+ var zde *filters.ZeroDivisionError
+ require.True(t, errors.As(err, &re), "outer wrapper must be *render.RenderError")
+ require.True(t, errors.As(err, &zde), "inner cause must be *filters.ZeroDivisionError")
+}
+
+// C4 — Non-zero divisor: no error, correct result.
+func TestS10C4_ZeroDivisionError_NonZero_NoError(t *testing.T) {
+ out := s10render(t, s10eng(t), `{{ 10 | divided_by: 2 }}`, nil)
+ assert.Equal(t, "5", out)
+}
+
+func TestS10C4_ZeroDivisionError_Modulo_NonZero_NoError(t *testing.T) {
+ out := s10render(t, s10eng(t), `{{ 10 | modulo: 3 }}`, nil)
+ assert.Equal(t, "1", out)
+}
+
+// C5 — ZeroDivisionError has a meaningful Error() message.
+func TestS10C5_ZeroDivisionError_Message(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 1 | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var zde *filters.ZeroDivisionError
+ require.True(t, errors.As(err, &zde))
+ assert.NotEmpty(t, zde.Error())
+ // Typically "divided by 0" or similar phrasing
+ assert.True(t,
+ strings.Contains(zde.Error(), "0") || strings.Contains(strings.ToLower(zde.Error()), "divis"),
+ "ZeroDivisionError message should mention zero or division: %q", zde.Error())
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// D. ArgumentError / ContextError
+// ═════════════════════════════════════════════════════════════════════════════
+
+// D1 — Filter returning *render.ArgumentError → detectable via errors.As.
+func TestS10D1_ArgumentError_FromFilter(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("bad_args_d1", func(v any) (any, error) {
+ return nil, render.NewArgumentError("argument error from filter")
+ })
+ err := s10renderErr(t, eng, `{{ "x" | bad_args_d1 }}`, nil)
+ var ae *render.ArgumentError
+ require.True(t, errors.As(err, &ae), "expected *render.ArgumentError, got %T: %v", err, err)
+}
+
+// D2 — Tag returning *render.ArgumentError → detectable via errors.As.
+func TestS10D2_ArgumentError_FromTag(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterTag("bad_tag_d2", func(c render.Context) (string, error) {
+ return "", render.NewArgumentError("argument error from tag")
+ })
+ err := s10renderErr(t, eng, `{% bad_tag_d2 %}`, nil)
+ var ae *render.ArgumentError
+ require.True(t, errors.As(err, &ae), "expected *render.ArgumentError, got %T: %v", err, err)
+}
+
+// D3 — Tag returning *render.ContextError → detectable via errors.As.
+func TestS10D3_ContextError_FromTag(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterTag("ctx_err_d3", func(c render.Context) (string, error) {
+ return "", render.NewContextError("context error from tag")
+ })
+ err := s10renderErr(t, eng, `{% ctx_err_d3 %}`, nil)
+ var ce *render.ContextError
+ require.True(t, errors.As(err, &ce), "expected *render.ContextError, got %T: %v", err, err)
+}
+
+// D4 — ArgumentError message propagated through chain.
+func TestS10D4_ArgumentError_MessageInChain(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("bad_args_d4", func(v any) (any, error) {
+ return nil, render.NewArgumentError("this is my specific argument error message")
+ })
+ err := s10renderErr(t, eng, `{{ x | bad_args_d4 }}`, map[string]any{"x": 1})
+ var ae *render.ArgumentError
+ require.True(t, errors.As(err, &ae))
+ assert.Equal(t, "this is my specific argument error message", ae.Error())
+}
+
+// D5 — ContextError message propagated through chain.
+func TestS10D5_ContextError_MessageInChain(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterTag("ctx_err_d5", func(c render.Context) (string, error) {
+ return "", render.NewContextError("ctx-specific error message")
+ })
+ err := s10renderErr(t, eng, `{% ctx_err_d5 %}`, nil)
+ var ce *render.ContextError
+ require.True(t, errors.As(err, &ce))
+ assert.Equal(t, "ctx-specific error message", ce.Error())
+}
+
+// D6 — Error string from filter has "Liquid error" prefix (not "Liquid syntax error").
+func TestS10D6_ArgumentError_FullErrorHasLiquidErrorPrefix(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("bad_args_d6", func(v any) (any, error) {
+ return nil, render.NewArgumentError("bad arg")
+ })
+ err := s10renderErr(t, eng, `{{ 1 | bad_args_d6 }}`, nil)
+ assert.Contains(t, err.Error(), "Liquid error")
+ assert.NotContains(t, err.Error(), "Liquid syntax error")
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// E. UndefinedVariableError
+// ═════════════════════════════════════════════════════════════════════════════
+
+// E1 — Default (non-strict) mode: undefined variable → empty string, no error.
+func TestS10E1_UndefinedVar_DefaultMode_NoError(t *testing.T) {
+ out := s10render(t, s10eng(t), `X{{ missing_var_e1 }}Y`, nil)
+ assert.Equal(t, "XY", out)
+}
+
+func TestS10E1_UndefinedVar_DefaultMode_NestedProperty(t *testing.T) {
+ out := s10render(t, s10eng(t), `{{ user.name }}`, nil)
+ assert.Equal(t, "", out)
+}
+
+// E2 — StrictVariables(): undefined var → *render.UndefinedVariableError.
+func TestS10E2_UndefinedVar_StrictMode_ReturnsError(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ err := s10renderErr(t, eng, `{{ missing_var_e2 }}`, map[string]any{})
+ var ue *render.UndefinedVariableError
+ require.True(t, errors.As(err, &ue), "strict mode must produce *render.UndefinedVariableError, got %T", err)
+}
+
+// E3 — Name field set to the root variable name (not a property path).
+func TestS10E3_UndefinedVar_NameField_Simple(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ err := s10renderErr(t, eng, `{{ my_missing_var }}`, map[string]any{})
+ var ue *render.UndefinedVariableError
+ require.True(t, errors.As(err, &ue))
+ assert.Equal(t, "my_missing_var", ue.Name)
+}
+
+func TestS10E3_UndefinedVar_NameField_DottedAccessPreservesRoot(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ // user.name → root Name should be "user"
+ err := s10renderErr(t, eng, `{{ user.name }}`, map[string]any{})
+ var ue *render.UndefinedVariableError
+ require.True(t, errors.As(err, &ue))
+ assert.Equal(t, "user", ue.Name)
+}
+
+// E4 — Line number and markup context correct.
+func TestS10E4_UndefinedVar_LineNumber(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ src := "before\n{{ missing_e4 }}\nafter"
+ err := s10renderErr(t, eng, src, map[string]any{})
+ assert.Contains(t, err.Error(), "line 2")
+}
+
+func TestS10E4_UndefinedVar_MarkupContext(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ err := s10renderErr(t, eng, `{{ my_undefined_e4 }}`, map[string]any{})
+ var ue *render.UndefinedVariableError
+ require.True(t, errors.As(err, &ue))
+ // MarkupContext should be the expression source
+ assert.Contains(t, ue.MarkupContext(), "my_undefined_e4")
+}
+
+// E5 — Per-render WithStrictVariables() works the same as engine-level.
+func TestS10E5_UndefinedVar_PerRender_WithStrictVariables(t *testing.T) {
+ eng := s10eng(t) // engine is non-strict
+ tpl := s10mustParse(t, eng, `{{ missing_e5 }}`)
+ // Per-render option enforces strict
+ _, err := tpl.RenderString(map[string]any{}, liquid.WithStrictVariables())
+ require.Error(t, err)
+ var ue *render.UndefinedVariableError
+ require.True(t, errors.As(err, &ue), "WithStrictVariables() must produce UndefinedVariableError")
+}
+
+func TestS10E5_UndefinedVar_PerRender_WithStrictVariables_DefinedIsOk(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ defined_e5 }}`)
+ out, err := tpl.RenderString(map[string]any{"defined_e5": "hello"}, liquid.WithStrictVariables())
+ require.NoError(t, err)
+ assert.Equal(t, "hello", string(out))
+}
+
+// E6 — errors.As chain walking: UndefinedVariableError findable from outer error.
+func TestS10E6_UndefinedVar_ErrorsAs_Chain(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ err := s10renderErr(t, eng, `{{ e6_var }}`, map[string]any{})
+ // Must find via errors.As regardless of intermediate wrapping
+ var ue *render.UndefinedVariableError
+ require.True(t, errors.As(err, &ue), "UndefinedVariableError must be findable via errors.As, got %T", err)
+}
+
+// E7 — Defined variable with StrictVariables: no error, correct output.
+func TestS10E7_UndefinedVar_DefinedVar_NoError(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ out := s10render(t, eng, `{{ greeting_e7 }}`, map[string]any{"greeting_e7": "hi"})
+ assert.Equal(t, "hi", out)
+}
+
+// E8 — Error prefix for UndefinedVariableError is "Liquid error", not "Liquid syntax error".
+func TestS10E8_UndefinedVar_ErrorPrefix(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ err := s10renderErr(t, eng, `{{ missing_e8 }}`, map[string]any{})
+ assert.Contains(t, err.Error(), "Liquid error")
+ assert.NotContains(t, err.Error(), "Liquid syntax error")
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// F. WithErrorHandler (exception_renderer)
+// ═════════════════════════════════════════════════════════════════════════════
+
+// F1 — Handler output replaces the failing node text.
+func TestS10F1_ErrorHandler_ReplacesFailingNode(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_f1", func(v any) (any, error) {
+ return nil, errors.New("boom")
+ })
+ tpl := s10mustParse(t, eng, `before {{ "x" | fail_f1 }} after`)
+ out, err := tpl.RenderString(nil, liquid.WithErrorHandler(func(e error) string {
+ return "[ERROR]"
+ }))
+ require.NoError(t, err, "handler must absorb the error")
+ assert.Equal(t, "before [ERROR] after", out)
+}
+
+// F2 — Rendering continues after the failing node.
+func TestS10F2_ErrorHandler_ContinuesAfterFailure(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_f2", func(v any) (any, error) {
+ return nil, errors.New("f2 failure")
+ })
+ tpl := s10mustParse(t, eng, `A{{ "x" | fail_f2 }}B{{ "y" | upcase }}C`)
+ out, err := tpl.RenderString(nil, liquid.WithErrorHandler(func(e error) string {
+ return "X"
+ }))
+ require.NoError(t, err)
+ assert.Equal(t, "AXBYC", out)
+}
+
+// F3 — Multiple errors handled; output assembled in order.
+func TestS10F3_ErrorHandler_MultipleErrors(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_f3", func(v any) (any, error) {
+ return nil, errors.New("f3 error")
+ })
+ tpl := s10mustParse(t, eng, `{{ 1 | fail_f3 }}+{{ 2 | fail_f3 }}+{{ 3 | fail_f3 }}`)
+
+ var collected []error
+ out, err := tpl.RenderString(nil, liquid.WithErrorHandler(func(e error) string {
+ collected = append(collected, e)
+ return "E"
+ }))
+ require.NoError(t, err)
+ assert.Equal(t, "E+E+E", out)
+ assert.Len(t, collected, 3, "handler must be called once per failing node")
+}
+
+// F4 — Handler receives the error; errors.As works inside handler.
+func TestS10F4_ErrorHandler_ReceivesTypedError(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_f4", func(v any) (any, error) {
+ return nil, render.NewArgumentError("typed arg error")
+ })
+ tpl := s10mustParse(t, eng, `{{ "x" | fail_f4 }}`)
+
+ var sawArgErr bool
+ _, err := tpl.RenderString(nil, liquid.WithErrorHandler(func(e error) string {
+ var ae *render.ArgumentError
+ if errors.As(e, &ae) {
+ sawArgErr = true
+ }
+ return ""
+ }))
+ require.NoError(t, err)
+ assert.True(t, sawArgErr, "handler must receive the ArgumentError through the chain")
+}
+
+// F5 — Parse errors are NOT caught by the render error handler.
+func TestS10F5_ErrorHandler_ParseErrorsNotCaught(t *testing.T) {
+ eng := s10eng(t)
+ // Parse error (unclosed block) happens before render; handler cannot intercept it
+ _, parseErr := eng.ParseString(`{% for x in arr %}`)
+ require.Error(t, parseErr, "a parse error must occur")
+ // The error must be a parse error, not absorbed by any handler
+ var pe *parser.ParseError
+ require.True(t, errors.As(parseErr, &pe))
+}
+
+// F6 — Non-erroring nodes render correctly alongside failing nodes.
+func TestS10F6_ErrorHandler_HealthyNodesUnaffected(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_f6", func(v any) (any, error) {
+ return nil, errors.New("failure")
+ })
+ tpl := s10mustParse(t, eng, `{{ greeting }} world {{ "x" | fail_f6 }} !!`)
+ out, err := tpl.RenderString(map[string]any{"greeting": "hello"},
+ liquid.WithErrorHandler(func(e error) string { return "" }))
+ require.NoError(t, err)
+ assert.Equal(t, "hello world !!", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// G. markup_context metadata
+// ═════════════════════════════════════════════════════════════════════════════
+
+// G1 — Error() shows markup context of failing expression when no path set.
+func TestS10G1_MarkupContext_InErrorString_WhenNoPath(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ product.cost | divided_by: 0 }}`)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ // No path → markup context should appear in Error() string
+ assert.Contains(t, err.Error(), "product.cost")
+}
+
+// G2 — When multiple nodes fail, each carries its own markup context.
+func TestS10G2_MarkupContext_EachNodeHasOwnContext(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("ctx_fail_g2", func(v any) (any, error) {
+ return nil, errors.New("ctx_fail_g2 err")
+ })
+
+ var contexts []string
+ tpl := s10mustParse(t, eng, `{{ alpha | ctx_fail_g2 }} {{ beta | ctx_fail_g2 }}`)
+ _, _ = tpl.RenderString(nil, liquid.WithErrorHandler(func(e error) string {
+ var re *render.RenderError
+ if errors.As(e, &re) {
+ contexts = append(contexts, re.MarkupContext())
+ }
+ return ""
+ }))
+ // Each of the two failing nodes must have a different markup context
+ require.Len(t, contexts, 2)
+ assert.NotEqual(t, contexts[0], contexts[1], "each node must have its own markup context")
+ assert.Contains(t, contexts[0], "alpha")
+ assert.Contains(t, contexts[1], "beta")
+}
+
+// G3 — Inner markup context preserved over outer block source in nested structure.
+func TestS10G3_MarkupContext_InnerPreservedThroughBlock(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, "{% if true %}\n {{ 1 | divided_by: 0 }}\n{% endif %}")
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re))
+ // MarkupContext must refer to the inner {{ expr }}, not the outer {% if %}
+ mc := re.MarkupContext()
+ assert.Contains(t, mc, "divided_by",
+ "inner markup context must be preserved over outer block context, got: %q", mc)
+ assert.NotContains(t, mc, "if true",
+ "outer block source must NOT overwrite inner context, got: %q", mc)
+}
+
+// G4 — MarkupContext() returns empty string when error has no locatable info.
+func TestS10G4_MarkupContext_EmptyWhenNoSource(t *testing.T) {
+ tok := parser.Token{
+ SourceLoc: parser.SourceLoc{}, // no pathname, no line
+ Source: "",
+ }
+ err := parser.Errorf(&tok, "some error")
+ assert.Equal(t, "", err.MarkupContext())
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// H. Error chain walking
+// ═════════════════════════════════════════════════════════════════════════════
+
+// H1 — ZeroDivisionError walkable from top-level error without knowing intermediate types.
+func TestS10H1_Chain_ZeroDivision_Walkable(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, `{{ 8 | divided_by: 0 }}`)
+ _, top := tpl.RenderString(nil)
+ require.Error(t, top)
+ var zde *filters.ZeroDivisionError
+ require.True(t, errors.As(top, &zde),
+ "ZeroDivisionError must be findable via errors.As from top-level error, chain: %T → %v", top, top)
+}
+
+// H2 — ArgumentError walkable from top-level error.
+func TestS10H2_Chain_ArgumentError_Walkable(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("chain_test_h2", func(v any) (any, error) {
+ return nil, render.NewArgumentError("chain arg error")
+ })
+ top := s10renderErr(t, eng, `{{ 1 | chain_test_h2 }}`, nil)
+ var ae *render.ArgumentError
+ require.True(t, errors.As(top, &ae),
+ "ArgumentError must be findable via errors.As from top-level error, got %T", top)
+}
+
+// H3 — *render.RenderError always present in chain for render-time failures.
+func TestS10H3_Chain_RenderError_AlwaysPresent(t *testing.T) {
+ testCases := []struct {
+ name string
+ src string
+ }{
+ {"zero_division", `{{ 1 | divided_by: 0 }}`},
+ {"modulo_zero", `{{ 5 | modulo: 0 }}`},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, tc.src)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re),
+ "*render.RenderError must be in chain for %s, got %T", tc.name, err)
+ })
+ }
+}
+
+// H4 — *parser.ParseError always present in chain for parse-time failures.
+func TestS10H4_Chain_ParseError_AlwaysPresent(t *testing.T) {
+ testCases := []struct {
+ name string
+ src string
+ }{
+ {"unclosed_for", `{% for x in y %}`},
+ {"unclosed_if", `{% if cond %}`},
+ {"unknown_tag", `{% no_such_tag_h4 %}`},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := s10eng(t).ParseString(tc.src)
+ require.Error(t, err)
+ var pe *parser.ParseError
+ require.True(t, errors.As(err, &pe),
+ "*parser.ParseError must be in chain for %s, got %T", tc.name, err)
+ })
+ }
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// I. Prefix invariants (regression guard)
+// ═════════════════════════════════════════════════════════════════════════════
+
+// I1 — Every parse-time error starts with "Liquid syntax error".
+func TestS10I1_Prefix_AllParseErrors_HaveSyntaxErrorPrefix(t *testing.T) {
+ cases := []struct {
+ name string
+ src string
+ }{
+ {"unclosed_for", `{% for a in b %}`},
+ {"unclosed_if", `{% if x %}`},
+ {"unclosed_case", `{% case x %}`},
+ {"unclosed_unless", `{% unless x %}`},
+ {"unclosed_capture", `{% capture v %}`},
+ {"unknown_tag_solo", `{% xyz_notregistered %}`},
+ {"unknown_tag_in_if", "{% if true %}\n{% xyz_in_if %}\n{% endif %}"},
+ {"invalid_operator", `{% if 1 =! 2 %}y{% endif %}`},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ _, err := s10eng(t).ParseString(c.src)
+ require.Error(t, err, "expected parse error for %q", c.src)
+ assert.True(t,
+ strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "parse error must start with 'Liquid syntax error', got: %q", err.Error())
+ })
+ }
+}
+
+// I2 — Every render-time error starts with "Liquid error", never "Liquid syntax error".
+func TestS10I2_Prefix_AllRenderErrors_HaveLiquidErrorPrefix(t *testing.T) {
+ buildEng := func(t *testing.T) *liquid.Engine {
+ t.Helper()
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_i2", func(v any) (any, error) {
+ return nil, errors.New("i2 render-time failure")
+ })
+ eng.RegisterTag("tag_fail_i2", func(c render.Context) (string, error) {
+ return "", errors.New("i2 tag failure")
+ })
+ return eng
+ }
+
+ cases := []struct {
+ name string
+ src string
+ }{
+ {"zero_division", `{{ 1 | divided_by: 0 }}`},
+ {"modulo_zero", `{{ 1 | modulo: 0 }}`},
+ {"filter_fails", `{{ "x" | fail_i2 }}`},
+ {"tag_fails", `{% tag_fail_i2 %}`},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ eng := buildEng(t)
+ tpl, parseErr := eng.ParseString(c.src)
+ require.NoError(t, parseErr)
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ assert.True(t,
+ strings.HasPrefix(err.Error(), "Liquid error"),
+ "render error must start with 'Liquid error', got: %q", err.Error())
+ assert.False(t,
+ strings.HasPrefix(err.Error(), "Liquid syntax error"),
+ "render error must NOT start with 'Liquid syntax error', got: %q", err.Error())
+ })
+ }
+}
+
+// I3 — Render error with line N includes "(line N)" in Error() string.
+func TestS10I3_Prefix_RenderError_LineN_InString(t *testing.T) {
+ eng := s10eng(t)
+ tpl := s10mustParse(t, eng, "ok\n{{ 1 | divided_by: 0 }}")
+ _, err := tpl.RenderString(nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "(line 2)",
+ "render error on line 2 must contain '(line 2)', got: %q", err.Error())
+}
+
+// I4 — Parse error with line N includes "(line N)" in Error() string.
+func TestS10I4_Prefix_ParseError_LineN_InString(t *testing.T) {
+ src := "ok\nok\n{% for_never_closed %}"
+ err := s10parseErr(t, src)
+ assert.Contains(t, err.Error(), "(line 3)",
+ "parse error on line 3 must contain '(line 3)', got: %q", err.Error())
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// Integration: realistic templates combining multiple section-10 features
+// ═════════════════════════════════════════════════════════════════════════════
+
+// TestS10_Integration_ErrorHandlerCollectsAllErrors demonstrates the canonical
+// pattern for collecting all render errors without stopping the output.
+func TestS10_Integration_ErrorHandlerCollectsAllErrors(t *testing.T) {
+ eng := s10eng(t)
+ eng.RegisterFilter("fail_collect", func(v any) (any, error) {
+ return nil, fmt.Errorf("item %v failed", v)
+ })
+
+ src := "start\n{{ 1 | fail_collect }}\nmiddle\n{{ 2 | fail_collect }}\nend"
+ tpl := s10mustParse(t, eng, src)
+
+ var errs []error
+ out, err := tpl.RenderString(nil, liquid.WithErrorHandler(func(e error) string {
+ errs = append(errs, e)
+ return ""
+ }))
+ require.NoError(t, err)
+ assert.Equal(t, "start\n\nmiddle\n\nend", out)
+ require.Len(t, errs, 2)
+ assert.Contains(t, errs[0].Error(), "1")
+ assert.Contains(t, errs[1].Error(), "2")
+}
+
+// TestS10_Integration_StrictVariables_MultipleUndefined collects all
+// UndefinedVariableErrors from a template in a single render via handler.
+func TestS10_Integration_StrictVariables_MultipleUndefined(t *testing.T) {
+ eng := s10eng(t)
+ eng.StrictVariables()
+ tpl := s10mustParse(t, eng, `{{ a }} and {{ b }} and {{ c }}`)
+
+ var names []string
+ out, err := tpl.RenderString(map[string]any{}, liquid.WithErrorHandler(func(e error) string {
+ var ue *render.UndefinedVariableError
+ if errors.As(e, &ue) {
+ names = append(names, ue.Name)
+ }
+ return "?"
+ }))
+ require.NoError(t, err)
+ assert.Equal(t, "? and ? and ?", out)
+ require.Len(t, names, 3)
+ assert.Contains(t, names, "a")
+ assert.Contains(t, names, "b")
+ assert.Contains(t, names, "c")
+}
+
+// TestS10_Integration_ZeroDivision_LineAndContext verifies that a ZeroDivision
+// error in a multi-line template has correct line number AND markup context.
+func TestS10_Integration_ZeroDivision_LineAndContext(t *testing.T) {
+ eng := s10eng(t)
+ src := "{% assign price = 100 %}\n{% assign discount = 0 %}\n{{ price | divided_by: discount }}"
+ tpl := s10mustParse(t, eng, src)
+ _, err := tpl.RenderString(map[string]any{"price": 100, "discount": 0})
+ require.Error(t, err)
+
+ assert.Contains(t, err.Error(), "line 3", "error must be on line 3")
+ assert.Contains(t, err.Error(), "divided_by", "error must mention the filter")
+
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re))
+
+ var zde *filters.ZeroDivisionError
+ require.True(t, errors.As(err, &zde))
+}
+
+// TestS10_Integration_NestedBlock_ErrorBubbles validates that an error deep in
+// a nested block structure carries accurate line and context metadata.
+func TestS10_Integration_NestedBlock_ErrorBubbles(t *testing.T) {
+ eng := s10eng(t)
+ src := "{% if true %}\n {% for i in arr %}\n {{ i | divided_by: 0 }}\n {% endfor %}\n{% endif %}"
+ tpl := s10mustParse(t, eng, src)
+ _, err := tpl.RenderString(map[string]any{"arr": []int{1}})
+ require.Error(t, err)
+
+ // Error must be attributed to line 3 (the divided_by: 0 expression)
+ assert.Contains(t, err.Error(), "line 3")
+ // The inner markup context must be preserved (not replaced by {% for %} or {% if %} source)
+ var re *render.RenderError
+ require.True(t, errors.As(err, &re))
+ mc := re.MarkupContext()
+ assert.Contains(t, mc, "divided_by",
+ "inner context must survive bubbling through nested blocks: %q", mc)
+}
diff --git a/s11_whitespace_e2e_test.go b/s11_whitespace_e2e_test.go
new file mode 100644
index 00000000..b233e900
--- /dev/null
+++ b/s11_whitespace_e2e_test.go
@@ -0,0 +1,871 @@
+package liquid_test
+
+// s11_whitespace_e2e_test.go — Intensive E2E tests for Section 11: Whitespace Control
+//
+// Coverage matrix:
+// A. Inline trim markers: {%- -%} and {{- -}} in every meaningful direction and context
+// B. {{-}} trim-blank (empty expression with trim marker) — regression guard for the fix
+// C. Global trim options: TrimTagLeft, TrimTagRight, TrimOutputLeft, TrimOutputRight
+// D. Greedy vs. non-greedy trim semantics
+// E. Interaction: inline markers + global options (must not double-apply)
+// F. All tag types with inline trim: for, if, unless, case, assign, capture, liquid, raw, comment
+// G. Edge cases: empty output, multi-line, adjacent markers, strings with whitespace
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/osteele/liquid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+func wsEngine(t *testing.T, opts ...func(*liquid.Engine)) *liquid.Engine {
+ t.Helper()
+ eng := liquid.NewEngine()
+ for _, o := range opts {
+ o(eng)
+ }
+ return eng
+}
+
+func wsRender(t *testing.T, eng *liquid.Engine, tpl string, binds map[string]any) string {
+ t.Helper()
+ out, err := eng.ParseAndRenderString(tpl, binds)
+ require.NoError(t, err, "template: %q", tpl)
+ return out
+}
+
+func wsRenderPlain(t *testing.T, tpl string) string {
+ t.Helper()
+ return wsRender(t, wsEngine(t), tpl, nil)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// A. Inline trim markers — all four combinations on tags
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Inline_Tag_NoTrim_PreservesAll(t *testing.T) {
+ // {% if %}...{% endif %} preserves surrounding whitespace completely
+ got := wsRenderPlain(t, " \n {% if true %} yes {% endif %} \n ")
+ require.Equal(t, " \n yes \n ", got)
+}
+
+func TestS11_Inline_Tag_TrimLeft_OnOpen(t *testing.T) {
+ // {%- if %}: trims whitespace to the LEFT of the opening tag
+ got := wsRenderPlain(t, " \n {%- if true %} yes {% endif %} ")
+ require.Equal(t, " yes ", got)
+}
+
+func TestS11_Inline_Tag_TrimRight_OnOpen(t *testing.T) {
+ // {% if -%}: trims whitespace to the RIGHT of the opening tag.
+ // -%} consumes the " " between the tag and "yes"; outer " " (before the {%if%}) is kept.
+ require.Equal(t, " yes ", wsRenderPlain(t, " {% if true -%} yes {% endif %} "))
+}
+
+func TestS11_Inline_Tag_TrimRight_OnOpen_Correct(t *testing.T) {
+ // {% if true -%} eats " yes " up to the next non-whitespace — NO, it trims the
+ // whitespace text node that follows the tag, not the content. " yes " is only
+ // whitespace before the literal "yes" text — so only the space after -%} is eaten.
+ got := wsRenderPlain(t, "{% if true -%} yes {%- endif %}
")
+ require.Equal(t, "yes
", got)
+}
+
+func TestS11_Inline_Tag_TrimLeft_OnClose(t *testing.T) {
+ // {%- endif %}: trims whitespace to the LEFT of the closing tag
+ got := wsRenderPlain(t, "{% if true %} yes {%- endif %}
")
+ require.Equal(t, " yes
", got)
+}
+
+func TestS11_Inline_Tag_TrimRight_OnClose(t *testing.T) {
+ // {% endif -%}: trims whitespace to the RIGHT of the closing tag
+ got := wsRenderPlain(t, "{% if true %} yes {% endif -%}
")
+ require.Equal(t, " yes
", got)
+}
+
+func TestS11_Inline_Tag_TrimBoth_CollapseAll(t *testing.T) {
+ // {%- if -%}...{%- endif -%}: no surrounding whitespace survives
+ got := wsRenderPlain(t, " {%- if true -%} yes {%- endif -%} ")
+ require.Equal(t, "yes", got)
+}
+
+func TestS11_Inline_Tag_TrimBoth_FalseBranch_EmitsNothing(t *testing.T) {
+ // false branch: nothing rendered — surrounding ws is still consumed
+ got := wsRenderPlain(t, " {%- if false -%} no {%- endif -%} ")
+ require.Equal(t, "", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// A. Inline trim markers — output expressions {{ }}
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Inline_Output_NoTrim_PreservesAll(t *testing.T) {
+ got := wsRenderPlain(t, " {{ 'x' }} ")
+ require.Equal(t, " x ", got)
+}
+
+func TestS11_Inline_Output_TrimLeft(t *testing.T) {
+ // {{- 'x' }} eats whitespace before the output
+ got := wsRenderPlain(t, " \n {{- 'x' }} ")
+ require.Equal(t, "x ", got)
+}
+
+func TestS11_Inline_Output_TrimRight(t *testing.T) {
+ // {{ 'x' -}} eats whitespace after the output
+ got := wsRenderPlain(t, " {{ 'x' -}} \n ")
+ require.Equal(t, " x", got)
+}
+
+func TestS11_Inline_Output_TrimBoth(t *testing.T) {
+ got := wsRenderPlain(t, " \n {{- 'x' -}} \n ")
+ require.Equal(t, "x", got)
+}
+
+func TestS11_Inline_Output_TrimBoth_MultipleBlankLines(t *testing.T) {
+ // {{- -}} with several blank lines on both sides: all consumed
+ got := wsRenderPlain(t, "a\n\n\n{{- 'mid' -}}\n\n\nb")
+ require.Equal(t, "amidb", got)
+}
+
+func TestS11_Inline_Output_TrimRight_AdjacentOutput(t *testing.T) {
+ // right-trim on first output, no trim on second: whitespace between them consumed
+ got := wsRenderPlain(t, `{{ "a" -}}{{ "b" }} c`)
+ require.Equal(t, "ab c", got)
+}
+
+func TestS11_Inline_Output_TrimBoth_CommaJoined(t *testing.T) {
+ // Two trimmed outputs separated by a comma: both collapse to adjacent values
+ got := wsRenderPlain(t, " {{- 'John' -}},\n {{- '30' -}} ")
+ require.Equal(t, "John,30", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// A. Mixed tag + output trim directions
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Mixed_TagLeft_OutputRight(t *testing.T) {
+ // {%- if %} (trim left on tag open), {{ v -}} (trim right on output)
+ // {%- eats "\n " before the if; -}} eats " \n" after v; endif has no trim.
+ eng := wsEngine(t)
+ got := wsRender(t, eng, "\n {%- if true %}a{{ v -}} \n{% endif %}", map[string]any{"v": 1})
+ require.Equal(t, "a1", got)
+}
+
+func TestS11_Mixed_TrimRightTag_TrimLeftOutput(t *testing.T) {
+ // {% if -%} (trim right on open) followed by {{- v }} (trim left on output)
+ // -%} eats " " before {{-, so {{- has nothing left to trim.
+ eng := wsEngine(t)
+ got := wsRender(t, eng, "{% if true -%} {{- v }}{% endif %}", map[string]any{"v": "hi"})
+ require.Equal(t, "hi", got)
+}
+
+func TestS11_Mixed_ComplexInterleaved(t *testing.T) {
+ // Full interleaved scenario from Ruby test_complex_trim_output
+ src := " \n" +
+ "
\n" +
+ " {{- 'John' -}}\n" +
+ " {{- '30' -}}\n" +
+ "
\n" +
+ "
\n" +
+ " {{ 'John' -}}\n" +
+ " {{- '30' }}\n" +
+ " \n" +
+ "
\n" +
+ " {{- 'John' }}\n" +
+ " {{ '30' -}}\n" +
+ " \n" +
+ "
\n "
+ want := " \n
John30
\n
\n John30\n \n
John\n 30 \n
\n "
+ require.Equal(t, want, wsRenderPlain(t, src))
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// B. {{-}} trim blank — regression guard
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_TrimBlank_Basic(t *testing.T) {
+ // Ruby test_trim_blank: {{-}} trims surrounding whitespace, outputs nothing.
+ got := wsRenderPlain(t, "foo {{-}} bar")
+ require.Equal(t, "foobar", got)
+}
+
+func TestS11_TrimBlank_MultipleSpaces(t *testing.T) {
+ // Multiple surrounding spaces all consumed
+ got := wsRenderPlain(t, "a {{-}} b")
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_TrimBlank_WithNewlines(t *testing.T) {
+ // Newlines on both sides consumed
+ got := wsRenderPlain(t, "a\n\n{{-}}\n\nb")
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_TrimBlank_InMiddleOfText(t *testing.T) {
+ // {{-}} in the middle of a sentence collapses the space
+ got := wsRenderPlain(t, "hello {{-}} world")
+ require.Equal(t, "helloworld", got)
+}
+
+func TestS11_TrimBlank_AdjacentToContent(t *testing.T) {
+ // {{-}} immediately adjacent to content — no space to trim, no output
+ got := wsRenderPlain(t, "AB{{-}}CD")
+ require.Equal(t, "ABCD", got)
+}
+
+func TestS11_TrimBlank_Multiple(t *testing.T) {
+ // Multiple {{-}} in sequence — each is a no-op output with trim
+ got := wsRenderPlain(t, "a {{-}} {{-}} b")
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_TrimBlank_InsideForLoop(t *testing.T) {
+ // {{-}} inside a for loop body: TrimLeft=nothing (no preceding ws), TrimRight eats " " before {{ i }}
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{% for i in arr %}{{-}} {{ i }}{% endfor %}",
+ map[string]any{"arr": []int{1, 2, 3}})
+ // Per iteration: TrimLeft(nothing), TrimRight eats " " before {{ i }} → "1", "2", "3"
+ require.Equal(t, "123", got)
+}
+
+func TestS11_TrimBlank_NoParseError(t *testing.T) {
+ // Regression: {{-}} must NOT produce a parse/syntax error
+ eng := wsEngine(t)
+ _, err := eng.ParseString("{{-}}")
+ require.NoError(t, err, "{{-}} should parse without error")
+}
+
+func TestS11_TrimBlank_EmptyExpression_NoOutput(t *testing.T) {
+ // Explicitly verify that {{-}} produces no output bytes
+ got := wsRenderPlain(t, "{{-}}")
+ require.Equal(t, "", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// C. Global trim options — TrimTagLeft
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Global_TrimTagLeft_Basic(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ got := wsRender(t, eng, " \n \t{%if true%}foo{%endif%} ", nil)
+ require.Equal(t, "foo ", got)
+}
+
+func TestS11_Global_TrimTagLeft_MultipleSpaces(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ got := wsRender(t, eng, " {%if true%}ok{%endif%}", nil)
+ require.Equal(t, "ok", got)
+}
+
+func TestS11_Global_TrimTagLeft_DoesNotTrimOutput(t *testing.T) {
+ // TrimTagLeft trims whitespace text nodes before {% tags %}, but does NOT
+ // alter the VALUE rendered by {{ output }} expressions.
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ // Whitespace inside the body between tag-bound content is NOT affected by TrimTagLeft
+ got := wsRender(t, eng, "{%if true%}a {{name}} b{%endif%}", map[string]any{"name": "harttle"})
+ require.Equal(t, "a harttle b", got)
+}
+
+func TestS11_Global_TrimTagLeft_OnlyTrimsTagSide(t *testing.T) {
+ // Text AFTER the tag is not trimmed; only before is
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ got := wsRender(t, eng, " {%assign x = 1%} after", nil)
+ require.Equal(t, " after", got)
+}
+
+func TestS11_Global_TrimTagLeft_FalseBranch(t *testing.T) {
+ // Even when if renders nothing, the left trim still applied
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ got := wsRender(t, eng, " {%if false%}no{%endif%}done", nil)
+ require.Equal(t, "done", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// C. Global trim options — TrimTagRight
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Global_TrimTagRight_Basic(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagRight(true) })
+ got := wsRender(t, eng, "\t{%if true%}foo{%endif%} \n", nil)
+ require.Equal(t, "\tfoo", got)
+}
+
+func TestS11_Global_TrimTagRight_MultiLine(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagRight(true) })
+ // TrimRight is in the OUTER sequence after the block. It trims the text FOLLOWING
+ // the block tag. Text inside the body is not affected.
+ got := wsRender(t, eng, "{%if true%}foo{%endif%} after", nil)
+ // " " between endif and "after" consumed by TrimRight
+ require.Equal(t, "fooafter", got)
+}
+
+func TestS11_Global_TrimTagRight_DoesNotTrimOutput(t *testing.T) {
+ // TrimTagRight must NOT trim whitespace adjacent to {{ }} expressions
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagRight(true) })
+ got := wsRender(t, eng, "{%if true%}a {{name}} b{%endif%}", map[string]any{"name": "harttle"})
+ require.Equal(t, "a harttle b", got)
+}
+
+func TestS11_Global_TrimTagRight_DoesNotTrimOutputRight(t *testing.T) {
+ // After an output expression, TrimTagRight doesn't trigger (no tag right)
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagRight(true) })
+ got := wsRender(t, eng, "{{ 'x' }} suffix", nil)
+ require.Equal(t, "x suffix", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// C. Global trim options — TrimTagLeft + TrimTagRight combined
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Global_TrimTagBoth_CollapsesAroundTags(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimTagLeft(true)
+ e.SetTrimTagRight(true)
+ })
+ // Empty body: TrimLeft eats leading ws before {%if%}; TrimRight eats trailing ws after {%endif%}
+ got := wsRender(t, eng, " {%if true%}{%endif%} ", nil)
+ require.Equal(t, "", got)
+}
+
+func TestS11_Global_TrimTagBoth_ContentBetweenTags(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimTagLeft(true)
+ e.SetTrimTagRight(true)
+ })
+ got := wsRender(t, eng, " {%if true%}content{%endif%} ", nil)
+ require.Equal(t, "content", got)
+}
+
+func TestS11_Global_TrimTagBoth_PreservesOutputExpression(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimTagLeft(true)
+ e.SetTrimTagRight(true)
+ })
+ got := wsRender(t, eng, "{%if true%}a {{name}} b{%endif%}", map[string]any{"name": "harttle"})
+ require.Equal(t, "a harttle b", got)
+}
+
+func TestS11_Global_TrimTagBoth_MultipleStatements(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimTagLeft(true)
+ e.SetTrimTagRight(true)
+ })
+ // Each tag's left+right whitespace trimmed; content text preserved
+ got := wsRender(t, eng,
+ " {%assign a = 1%} {%assign b = 2%} {{ a }}+{{ b }}",
+ nil)
+ require.Equal(t, "1+2", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// C. Global trim options — TrimOutputLeft
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Global_TrimOutputLeft_Basic(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputLeft(true) })
+ got := wsRender(t, eng, " \n \t{{name}} ", map[string]any{"name": "harttle"})
+ require.Equal(t, "harttle ", got)
+}
+
+func TestS11_Global_TrimOutputLeft_DoesNotTrimTag(t *testing.T) {
+ // TrimOutputLeft must NOT trim whitespace adjacent to {% %} tags
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputLeft(true) })
+ got := wsRender(t, eng, "\t{% if true %} aha {%endif%}\t", nil)
+ require.Equal(t, "\t aha \t", got)
+}
+
+func TestS11_Global_TrimOutputLeft_MultipleOutputs(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputLeft(true) })
+ got := wsRender(t, eng, " {{a}} {{b}} ", map[string]any{"a": 1, "b": 2})
+ // Left trim before each output: " {{a}}" → "1", " {{b}}" → "2"; trailing " " kept
+ require.Equal(t, "12 ", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// C. Global trim options — TrimOutputRight
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Global_TrimOutputRight_Basic(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputRight(true) })
+ got := wsRender(t, eng, " \n \t{{name}} ", map[string]any{"name": "harttle"})
+ require.Equal(t, " \n \tharttle", got)
+}
+
+func TestS11_Global_TrimOutputRight_DoesNotTrimTag(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputRight(true) })
+ got := wsRender(t, eng, "\t{% if true %} aha {%endif%}\t", nil)
+ require.Equal(t, "\t aha \t", got)
+}
+
+func TestS11_Global_TrimOutputRight_TrailingContentPreserved(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputRight(true) })
+ got := wsRender(t, eng, " {{v}} text", map[string]any{"v": "hi"})
+ // TrimOutputRight eats " " between output and "text"
+ require.Equal(t, " hitext", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// C. Global trim options — TrimOutputLeft + TrimOutputRight combined
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Global_TrimOutputBoth_CollapsesBothSides(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimOutputLeft(true)
+ e.SetTrimOutputRight(true)
+ })
+ got := wsRender(t, eng, " {{v}} ", map[string]any{"v": "mid"})
+ require.Equal(t, "mid", got)
+}
+
+func TestS11_Global_TrimOutputBoth_DoesNotTrimTags(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimOutputLeft(true)
+ e.SetTrimOutputRight(true)
+ })
+ got := wsRender(t, eng, "\t{% if true %} aha {%endif%}\t", nil)
+ require.Equal(t, "\t aha \t", got)
+}
+
+func TestS11_Global_TrimOutputBoth_MultipleOutputsTouching(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) {
+ e.SetTrimOutputLeft(true)
+ e.SetTrimOutputRight(true)
+ })
+ // " {{a}} {{b}} " → both outputs trimmed; "a" and "b" touch each other
+ got := wsRender(t, eng, " {{a}} {{b}} ", map[string]any{"a": "A", "b": "B"})
+ require.Equal(t, "AB", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// D. Greedy vs. non-greedy semantics
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Greedy_Default_IsTrue(t *testing.T) {
+ // Default greedy=true: all consecutive whitespace (incl. multiple newlines) trimmed
+ eng := wsEngine(t)
+ got := wsRender(t, eng, "\n\n\n{%- if true -%}\nhello\n{%- endif -%}\n\n\n", nil)
+ require.Equal(t, "hello", got)
+}
+
+func TestS11_Greedy_True_ConsumesAllNewlines(t *testing.T) {
+ eng := wsEngine(t, func(e *liquid.Engine) { /* default greedy=true */ })
+ got := wsRender(t, eng, "a\n\n\n{%- assign x = 1 -%}\n\n\nb", nil)
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_Greedy_False_ConsumesOnlyOneNewline(t *testing.T) {
+ // non-greedy {%- and -%} behavior:
+ // - TrimLeftNonGreedy removes only trailing INLINE-BLANK (space/tab) from buffer
+ // - TrimRightNonGreedy removes leading inline-blank + AT MOST 1 newline from next text
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetGreedy(false) })
+ // Template: trailing spaces before {%-, spaces+newline after -%}, then second newline
+ // -%} eats " " (inline blanks) + 1 newline → leaves second "\n"+"b"
+ src := "a {%- assign x = 1 -%} \n\nb"
+ got := wsRender(t, eng, src, nil)
+ // non-greedy: TrimLeft eats trailing " " from "a " → "a"
+ // non-greedy TrimRight: eats " " (inline blanks) + 1 "\n" → leaves "\nb"
+ require.Equal(t, "a\nb", got)
+}
+
+func TestS11_Greedy_False_InlineBlankBeforeNewline(t *testing.T) {
+ // Non-greedy TrimRight eats inline-blank + 1 newline; extra newlines are preserved.
+ // non-greedy TrimLeft eats only trailing inline-blank chars NOT newlines.
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetGreedy(false) })
+ // Source: spaces before {%-, two newlines after -%}
+ // TrimLeft(NG): "a " → trailing spaces removed → "a" written.
+ // TrimRight(NG) on " \n\nb": spaces eaten, then 1 newline eaten → "\nb" remains.
+ got := wsRender(t, eng, "a {%- assign x = 1 -%} \n\nb", nil)
+ require.Equal(t, "a\nb", got)
+}
+
+func TestS11_Greedy_False_PreservesExtraNewlines(t *testing.T) {
+ // Non-greedy: two trailing newlines — only one consumed, second preserved
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetGreedy(false) })
+ src := "\n {%-if true-%}\n a \n{{-name-}}{%-endif-%}\n "
+ got := wsRender(t, eng, src, map[string]any{"name": "harttle"})
+ // Exactly matches ported test TestWhitespaceCtrl_Greedy_False
+ require.Equal(t, "\n a \nharttle ", got)
+}
+
+func TestS11_Greedy_True_SameSource(t *testing.T) {
+ // Same source as above with greedy=true
+ eng := wsEngine(t)
+ src := "\n {%-if true-%}\n a \n{{-name-}}{%-endif-%}\n "
+ got := wsRender(t, eng, src, map[string]any{"name": "harttle"})
+ require.Equal(t, "aharttle", got)
+}
+
+func TestS11_Greedy_ToggleProducesDistinctOutputs(t *testing.T) {
+ // The same template must NEVER produce the same output in greedy vs non-greedy
+ // whenever there are multiple consecutive whitespace chars.
+ src := "a\n\n{%- assign x = 1 -%}\n\nb"
+ engG := wsEngine(t)
+ engNG := wsEngine(t, func(e *liquid.Engine) { e.SetGreedy(false) })
+ outG := wsRender(t, engG, src, nil)
+ outNG := wsRender(t, engNG, src, nil)
+ assert.NotEqual(t, outG, outNG, "greedy and non-greedy must differ on multi-newline input")
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// E. Interaction: inline markers + global options
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Interaction_InlineMarkerWithGlobalTagTrim_NoDoubleApply(t *testing.T) {
+ // Global TrimTagLeft + inline {%- should still work correctly (not double-trim)
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ got := wsRender(t, eng, " {%- if true %}content{%endif%}", nil)
+ // Both the global trim and inline {%- trim the left — result is same: "content"
+ require.Equal(t, "content", got)
+}
+
+func TestS11_Interaction_InlineOutputMarkerNotAffectedByGlobalTagTrim(t *testing.T) {
+ // Global TrimTagLeft trims whitespace TEXT NODES before {% tags %}.
+ // It does NOT trim the value rendered by {{ output }} expressions.
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagLeft(true) })
+ // The " " between {{ 'x' }} and {%if%} is a text node: TrimLeft eats it.
+ // But the value of 'x' itself and the outer " " before {{ }} are not touched.
+ got := wsRender(t, eng, " {{ 'x' }} {%if true%}y{%endif%}", nil)
+ require.Equal(t, " xy", got)
+}
+
+func TestS11_Interaction_GlobalOutputTrim_WithInlineTagTrim(t *testing.T) {
+ // Global TrimOutputLeft + inline {%- tag trim: both active independently
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimOutputLeft(true) })
+ got := wsRender(t, eng, " {{ v }} {%- if true %}ok{% endif %}", map[string]any{"v": 1})
+ // Output left trim: " {{ v }}" → leading " " consumed → "1"
+ // {%- tag: trim left before the if → " " before if consumed
+ // Result: "1ok"
+ require.Equal(t, "1ok", got)
+}
+
+func TestS11_Interaction_GlobalTagTrim_WithInlineOutputTrim(t *testing.T) {
+ // Global TrimTagRight trims the text FOLLOWING a block (in outer context).
+ // It does NOT affect text inside the body that follows an output expression.
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagRight(true) })
+ got := wsRender(t, eng, "{%if true%} {{- v }} {%endif%} end", map[string]any{"v": "hi"})
+ // Body: TrimLeft (from {{-) eats leading " "; outputs "hi"; trailing " " stays in body.
+ // TrimRight (global, after endif in outer) eats " " before "end".
+ require.Equal(t, "hi end", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// F. All tag types — inline trim works for every supported tag
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Tags_For_TrimBoth(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "\n{%- for i in arr -%}\n{{ i }}\n{%- endfor -%}\n",
+ map[string]any{"arr": []int{1, 2, 3}})
+ // Body: TrimRight (from -%} on for open) eats "\n" before {{ i }};
+ // TrimLeft (from {%- on endfor) eats "\n" after {{ i }}.
+ // Per-iteration: "1", "2", "3" — all adjacent.
+ require.Equal(t, "123", got)
+}
+
+func TestS11_Tags_For_TrimRight_Open_TrimLeft_Close(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{% for i in arr -%}\n{{ i }}\n{%- endfor %}",
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "123", got)
+}
+
+func TestS11_Tags_For_Reversed_TrimBoth(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{%- for i in arr reversed -%}{{ i }}{%- endfor -%}",
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "321", got)
+}
+
+func TestS11_Tags_For_WithRange_TrimBoth(t *testing.T) {
+ got := wsRenderPlain(t, "{%- for i in (1..3) -%}{{ i }}{%- endfor -%}")
+ require.Equal(t, "123", got)
+}
+
+func TestS11_Tags_For_Limit_TrimBoth(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{%- for i in arr limit: 2 -%}{{ i }}{%- endfor -%}",
+ map[string]any{"arr": []int{1, 2, 3, 4}})
+ require.Equal(t, "12", got)
+}
+
+func TestS11_Tags_For_Offset_TrimBoth(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{%- for i in arr offset: 1 -%}{{ i }}{%- endfor -%}",
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "23", got)
+}
+
+func TestS11_Tags_For_Else_TrimBoth(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "\n{%- for i in arr -%}{{ i }}{%- else -%} none {%- endfor -%}\n",
+ map[string]any{"arr": []int{}})
+ require.Equal(t, "none", got)
+}
+
+func TestS11_Tags_If_AllForms_TrimBoth(t *testing.T) {
+ // if / elsif / else / endif all with trim
+ eng := wsEngine(t)
+ for _, tc := range []struct {
+ v int
+ want string
+ }{
+ {1, "one"},
+ {2, "two"},
+ {3, "other"},
+ } {
+ t.Run(fmt.Sprintf("v=%d", tc.v), func(t *testing.T) {
+ got := wsRender(t, eng,
+ "{%- if v == 1 -%}one{%- elsif v == 2 -%}two{%- else -%}other{%- endif -%}",
+ map[string]any{"v": tc.v})
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestS11_Tags_Unless_TrimBoth(t *testing.T) {
+ got := wsRenderPlain(t, " {%- unless false -%} yes {%- endunless -%} ")
+ require.Equal(t, "yes", got)
+}
+
+func TestS11_Tags_Case_TrimBoth(t *testing.T) {
+ eng := wsEngine(t)
+ for _, tc := range []struct {
+ v int
+ want string
+ }{
+ {1, "one"},
+ {2, "two"},
+ {99, "other"},
+ } {
+ t.Run(fmt.Sprintf("v=%d", tc.v), func(t *testing.T) {
+ got := wsRender(t, eng,
+ " {%- case v -%} {%- when 1 -%}one{%- when 2 -%}two{%- else -%}other{%- endcase -%} ",
+ map[string]any{"v": tc.v})
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestS11_Tags_Assign_TrimBoth_Invisible(t *testing.T) {
+ // assign renders nothing; TrimLeft eats all trailing whitespace from before;
+ // TrimRight eats all leading whitespace from after.
+ got := wsRenderPlain(t, "before \n {%- assign x = 42 -%} \n after{{ x }}")
+ // TrimLeft: "before \n " → trailing ws trimmed → "before"
+ // TrimRight: " \n after" → leading ws trimmed → "after"
+ require.Equal(t, "beforeafter42", got)
+}
+
+func TestS11_Tags_Capture_TrimBoth(t *testing.T) {
+ // capture with trim on both block delimiters
+ got := wsRenderPlain(t, "{%- capture msg -%} hello world {%- endcapture -%}[{{ msg }}]")
+ require.Equal(t, "[hello world]", got)
+}
+
+func TestS11_Tags_Increment_TrimBoth(t *testing.T) {
+ // increment outputs a value; trim markers collapse surrounding whitespace
+ got := wsRenderPlain(t, " {%- increment v -%} ")
+ require.Equal(t, "0", got)
+}
+
+func TestS11_Tags_Decrement_TrimBoth(t *testing.T) {
+ got := wsRenderPlain(t, " {%- decrement v -%} ")
+ require.Equal(t, "-1", got)
+}
+
+func TestS11_Tags_Echo_TrimBoth(t *testing.T) {
+ got := wsRenderPlain(t, " {%- echo 'hi' -%} ")
+ require.Equal(t, "hi", got)
+}
+
+func TestS11_Tags_InlineComment_TrimBoth(t *testing.T) {
+ // {%- # comment -%} trims both sides, outputs nothing
+ got := wsRenderPlain(t, "a \n{%- # inline comment -%}\n b")
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_Tags_InlineComment_WithSpace_TrimBoth(t *testing.T) {
+ // {%- # comment -%} (space after dash) — the B3 bug fix variant
+ got := wsRenderPlain(t, "a \n{%- # comment with space -%}\n b")
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_Tags_LiquidTag_TrimBoth(t *testing.T) {
+ // {%- liquid ... -%} multi-statement tag with trim
+ got := wsRenderPlain(t, " \n{%- liquid\n assign a = 1\n assign b = 2\n-%}\n {{ a }}+{{ b }}")
+ require.Equal(t, "1+2", got)
+}
+
+func TestS11_Tags_Raw_ExternalTrim_DoesNotAffectContent(t *testing.T) {
+ // {%- raw -%}: trim markers on the raw tag trim OUTSIDE whitespace only
+ // Content inside raw is emitted verbatim (trim markers inside are literal)
+ got := wsRenderPlain(t, "before \n{%- raw -%}\n{%- {{- verbatim -}} -%}\n{%- endraw -%}\n after")
+ require.Equal(t, "before\n{%- {{- verbatim -}} -%}\nafter", got)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// F. Deeply nested tag combinations
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Nested_ForInIf_TrimAll(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{%- if show -%}\n{%- for i in arr -%}{{ i }}{%- endfor -%}\n{%- endif -%}",
+ map[string]any{"show": true, "arr": []int{1, 2, 3}})
+ require.Equal(t, "123", got)
+}
+
+func TestS11_Nested_ForInIf_TrimAll_FalseBranch(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "x{%- if show -%}{%- for i in arr -%}{{ i }}{%- endfor -%}{%- endif -%}y",
+ map[string]any{"show": false, "arr": []int{1, 2, 3}})
+ require.Equal(t, "xy", got)
+}
+
+func TestS11_Nested_IfInFor_ConditionFilter(t *testing.T) {
+ // Filter applied in a nested if condition inside a for loop with trim
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{%- for item in arr -%}{%- if item.size > 3 -%}{{ item }}{%- endif -%}{%- endfor -%}",
+ map[string]any{"arr": []string{"hi", "hello", "hey", "world"}})
+ require.Equal(t, "helloworld", got)
+}
+
+func TestS11_Nested_ThreeLevels_TrimAll(t *testing.T) {
+ eng := wsEngine(t)
+ got := wsRender(t, eng,
+ "{%- for i in arr -%}{%- for j in arr -%}{%- if i == j -%}{{ i }}{%- endif -%}{%- endfor -%}{%- endfor -%}",
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "123", got)
+}
+
+func TestS11_Nested_DeepStructure_AllTrimmed(t *testing.T) {
+ // Ruby test_complex_trim: nested if + output markers fully collapse whitespace
+ src := " \n" +
+ " {%- if true -%}\n" +
+ " {%- if true -%}\n" +
+ "
\n" +
+ " {{- 'John' -}}\n" +
+ "
\n" +
+ " {%- endif -%}\n" +
+ " {%- endif -%}\n" +
+ "
\n "
+ want := " \n "
+ require.Equal(t, want, wsRenderPlain(t, src))
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// G. Edge cases
+// ─────────────────────────────────────────────────────────────────────────────
+
+func TestS11_Edge_EmptyTemplate(t *testing.T) {
+ got := wsRenderPlain(t, "")
+ require.Equal(t, "", got)
+}
+
+func TestS11_Edge_OnlyTrimMarkers(t *testing.T) {
+ // A template that is only trim markers with no content
+ got := wsRenderPlain(t, "{%- assign x = 1 -%}")
+ require.Equal(t, "", got)
+}
+
+func TestS11_Edge_TrimDoesNotAffectStringContent(t *testing.T) {
+ // Trim should not remove whitespace WITHIN string literal values
+ got := wsRenderPlain(t, "{%- assign v = ' hello ' -%}[{{ v }}]")
+ require.Equal(t, "[ hello ]", got)
+}
+
+func TestS11_Edge_TrimAcrossMultipleNodes(t *testing.T) {
+ // Right trim on one output, left trim on next output — space between consumed
+ got := wsRenderPlain(t, "{{ 'a' -}} {{ 'b' -}} {{ 'c' }}")
+ require.Equal(t, "abc", got)
+}
+
+func TestS11_Edge_TrimLeftPreservesNonWhitespaceOnLeft(t *testing.T) {
+ // {%- tag does NOT trim non-whitespace characters to the left
+ got := wsRenderPlain(t, "abc{%- if true %}yes{% endif %}")
+ require.Equal(t, "abcyes", got)
+}
+
+func TestS11_Edge_TrimRightPreservesNonWhitespaceOnRight(t *testing.T) {
+ // tag -%} does NOT trim non-whitespace characters to the right
+ got := wsRenderPlain(t, "{% if true -%}abc{% endif %}")
+ require.Equal(t, "abc", got)
+}
+
+func TestS11_Edge_TrimWithTabCharacters(t *testing.T) {
+ // Trim should also consume tab characters
+ got := wsRenderPlain(t, "\t\t{%- if true -%}\t\tcontent\t\t{%- endif -%}\t\t")
+ require.Equal(t, "content", got)
+}
+
+func TestS11_Edge_TrimWithCarriageReturn(t *testing.T) {
+ // \r\n line endings: trim should handle them
+ got := wsRenderPlain(t, "a\r\n{%- assign x = 1 -%}\r\nb")
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_Edge_TrimBlank_InsideCapture(t *testing.T) {
+ // {{-}} inside a capture block: trims whitespace, outputs nothing
+ got := wsRenderPlain(t, "{%- capture c -%} {{-}} hello{{-}} {%- endcapture -%}[{{ c }}]")
+ require.Equal(t, "[hello]", got)
+}
+
+func TestS11_Edge_TrimBlank_Next_ToOtherOutput(t *testing.T) {
+ // {{-}} immediately before a real output: trims the space, output follows
+ got := wsRender(t, wsEngine(t), "{{-}} {{ v }}", map[string]any{"v": "x"})
+ require.Equal(t, "x", got)
+}
+
+func TestS11_Edge_GlobalTrimTag_DoesNotAffectRawContent(t *testing.T) {
+ // Global TrimTagRight is NOT applied to RawNode (special case: raw blocks bypass
+ // the TrimNode injection in compileNodes). Raw content is always emitted verbatim.
+ eng := wsEngine(t, func(e *liquid.Engine) { e.SetTrimTagRight(true) })
+ got := wsRender(t, eng, "{% raw %} {{- verbatim -}} {% endraw %}", nil)
+ // Raw block: content emitted verbatim, no TrimRight applied.
+ require.Equal(t, " {{- verbatim -}} ", got)
+}
+
+func TestS11_Edge_LargeWhitespaceBlob_GreedyConsumesAll(t *testing.T) {
+ // Greedy mode should consume an arbitrarily large whitespace blob
+ bigWS := strings.Repeat("\n \t", 20) // 20 repetitions of \n + spaces + tab
+ src := "a" + bigWS + "{%- assign x = 1 -%}" + bigWS + "b"
+ got := wsRenderPlain(t, src)
+ require.Equal(t, "ab", got)
+}
+
+func TestS11_Edge_TrimTag_EmptyForBody(t *testing.T) {
+ // for loop over empty array with trim — should produce nothing cleanly
+ got := wsRender(t, wsEngine(t),
+ " {%- for i in arr -%}{{ i }}{%- endfor -%} ",
+ map[string]any{"arr": []int{}})
+ require.Equal(t, "", got)
+}
+
+func TestS11_Edge_TrimTag_PreservesOutputInsideTag(t *testing.T) {
+ // Trim on the tag itself does not alter the VALUE of output inside the tag body
+ got := wsRender(t, wsEngine(t),
+ "{%- for i in arr -%} {{ i }} {%- endfor -%}",
+ map[string]any{"arr": []int{1, 2, 3}})
+ // -%} after for eats " " before "{{ i }}", {%- before endfor eats " " after "{{ i }}"
+ require.Equal(t, "123", got)
+}
+
+func TestS11_Edge_TrimBothSides_MultipleConsecutiveTags(t *testing.T) {
+ // Multiple consecutive tags all with trim: the whitespace between them collapses
+ got := wsRenderPlain(t,
+ " {%- assign a = 1 -%} {%- assign b = 2 -%} {%- assign c = 3 -%} {{ a }}{{ b }}{{ c }}")
+ require.Equal(t, "123", got)
+}
diff --git a/s1_tags_test.go b/s1_tags_test.go
new file mode 100644
index 00000000..63b2e189
--- /dev/null
+++ b/s1_tags_test.go
@@ -0,0 +1,1304 @@
+package liquid_test
+
+// S1 — Section 1 (Tags) intensive E2E tests.
+//
+// Exercises ALL Section 1 tag behaviours with Go-typed bindings and
+// complex template constructs. The intent is to serve as a regression
+// barrier: any unintended change to Section 1 behaviour should break
+// at least one test here.
+//
+// Sections covered:
+// 1.1 Output / Expression — {{ }}, echo
+// 1.2 Variables — assign, capture
+// 1.3 Conditionals — if/elsif/else, unless, case/when
+// 1.4 Iteration — for/else, modifiers, forloop vars,
+// break/continue, offset:continue, cycle, tablerow
+// 1.6 Structure — raw, comment
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/osteele/liquid"
+ "github.com/stretchr/testify/require"
+)
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+func renderS1(t *testing.T, tpl string, binds map[string]any) string {
+ t.Helper()
+ eng := liquid.NewEngine()
+ out, err := eng.ParseAndRenderString(tpl, binds)
+ require.NoError(t, err, "template: %s", tpl)
+ return out
+}
+
+func renderS1T(t *testing.T, tpl string) string {
+ t.Helper()
+ return renderS1(t, tpl, nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.1 Output / Expression — {{ variable }}
+// ═════════════════════════════════════════════════════════════════════════════
+
+// ── type rendering ────────────────────────────────────────────────────────────
+
+func TestS11_Output_String(t *testing.T) {
+ require.Equal(t, "hello", renderS1(t, "{{ v }}", map[string]any{"v": "hello"}))
+}
+
+func TestS11_Output_Int(t *testing.T) {
+ require.Equal(t, "42", renderS1(t, "{{ v }}", map[string]any{"v": 42}))
+}
+
+func TestS11_Output_NegativeInt(t *testing.T) {
+ require.Equal(t, "-7", renderS1(t, "{{ v }}", map[string]any{"v": -7}))
+}
+
+func TestS11_Output_Float(t *testing.T) {
+ require.Equal(t, "3.14", renderS1(t, "{{ v }}", map[string]any{"v": 3.14}))
+}
+
+func TestS11_Output_BoolTrue(t *testing.T) {
+ require.Equal(t, "true", renderS1(t, "{{ v }}", map[string]any{"v": true}))
+}
+
+func TestS11_Output_BoolFalse(t *testing.T) {
+ require.Equal(t, "false", renderS1(t, "{{ v }}", map[string]any{"v": false}))
+}
+
+func TestS11_Output_NilRendersEmpty(t *testing.T) {
+ require.Equal(t, "", renderS1(t, "{{ v }}", map[string]any{"v": nil}))
+}
+
+func TestS11_Output_MissingVariableRendersEmpty(t *testing.T) {
+ // unset variables are nil → render as empty string without error
+ require.Equal(t, "", renderS1T(t, "{{ totally_missing }}"))
+}
+
+// ── property traversal ────────────────────────────────────────────────────────
+
+func TestS11_Output_NestedMap(t *testing.T) {
+ out := renderS1(t, "{{ user.name }}", map[string]any{
+ "user": map[string]any{"name": "Alice"},
+ })
+ require.Equal(t, "Alice", out)
+}
+
+func TestS11_Output_DeeplyNestedMap(t *testing.T) {
+ out := renderS1(t, "{{ a.b.c.d }}", map[string]any{
+ "a": map[string]any{"b": map[string]any{"c": map[string]any{"d": "deep"}}},
+ })
+ require.Equal(t, "deep", out)
+}
+
+func TestS11_Output_GoStruct(t *testing.T) {
+ type Product struct {
+ Name string
+ Price float64
+ }
+ out := renderS1(t, "{{ p.Name }}: {{ p.Price }}", map[string]any{
+ "p": Product{Name: "Widget", Price: 9.99},
+ })
+ require.Equal(t, "Widget: 9.99", out)
+}
+
+func TestS11_Output_NestedStruct(t *testing.T) {
+ type Address struct{ City string }
+ type Person struct {
+ Name string
+ Address Address
+ }
+ out := renderS1(t, "{{ p.Name }} from {{ p.Address.City }}", map[string]any{
+ "p": Person{Name: "Bob", Address: Address{City: "Paris"}},
+ })
+ require.Equal(t, "Bob from Paris", out)
+}
+
+func TestS11_Output_MapInStruct(t *testing.T) {
+ type Wrapper struct{ Data map[string]any }
+ out := renderS1(t, "{{ w.Data.key }}", map[string]any{
+ "w": Wrapper{Data: map[string]any{"key": "found"}},
+ })
+ require.Equal(t, "found", out)
+}
+
+func TestS11_Output_NilPropertyAccess_NoError(t *testing.T) {
+ // accessing a key on a nil value renders empty string, not a panic
+ out := renderS1(t, "{{ x.missing }}", map[string]any{"x": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS11_Output_MissingNestedKey_NoError(t *testing.T) {
+ out := renderS1(t, "{{ user.address.zip }}", map[string]any{
+ "user": map[string]any{"name": "Alice"},
+ })
+ require.Equal(t, "", out)
+}
+
+// ── array access ──────────────────────────────────────────────────────────────
+
+func TestS11_Output_ArrayIndex(t *testing.T) {
+ out := renderS1(t, "{{ arr[1] }}", map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "b", out)
+}
+
+func TestS11_Output_ArrayIndex_Zero(t *testing.T) {
+ out := renderS1(t, "{{ arr[0] }}", map[string]any{"arr": []int{10, 20, 30}})
+ require.Equal(t, "10", out)
+}
+
+func TestS11_Output_ArrayFirst(t *testing.T) {
+ out := renderS1(t, "{{ arr.first }}", map[string]any{"arr": []int{11, 22, 33}})
+ require.Equal(t, "11", out)
+}
+
+func TestS11_Output_ArrayLast(t *testing.T) {
+ out := renderS1(t, "{{ arr.last }}", map[string]any{"arr": []int{11, 22, 33}})
+ require.Equal(t, "33", out)
+}
+
+func TestS11_Output_ArraySize(t *testing.T) {
+ out := renderS1(t, "{{ arr.size }}", map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "3", out)
+}
+
+// ── filters ───────────────────────────────────────────────────────────────────
+
+func TestS11_Output_SingleFilter(t *testing.T) {
+ out := renderS1(t, "{{ name | upcase }}", map[string]any{"name": "alice"})
+ require.Equal(t, "ALICE", out)
+}
+
+func TestS11_Output_FilterChain(t *testing.T) {
+ out := renderS1(t, "{{ s | downcase | capitalize }}", map[string]any{"s": "HELLO WORLD"})
+ require.Equal(t, "Hello world", out)
+}
+
+func TestS11_Output_FilterWithArg(t *testing.T) {
+ out := renderS1(t, `{{ s | prepend: "Mr. " }}`, map[string]any{"s": "Smith"})
+ require.Equal(t, "Mr. Smith", out)
+}
+
+func TestS11_Output_FilterOnNil_NoError(t *testing.T) {
+ // applying a filter to nil should not panic; renders empty
+ out := renderS1(t, "{{ v | upcase }}", map[string]any{"v": nil})
+ require.Equal(t, "", out)
+}
+
+// ── multiple outputs ──────────────────────────────────────────────────────────
+
+func TestS11_Output_Multiple_InTemplate(t *testing.T) {
+ out := renderS1(t, "{{ a }} + {{ b }} = {{ c }}", map[string]any{"a": 1, "b": 2, "c": 3})
+ require.Equal(t, "1 + 2 = 3", out)
+}
+
+func TestS11_Output_InterlevedTextAndTags(t *testing.T) {
+ out := renderS1(t, "Hello, {{ name }}! You are {{ age }} years old.",
+ map[string]any{"name": "Ana", "age": 28})
+ require.Equal(t, "Hello, Ana! You are 28 years old.", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.1 Output / Expression — echo tag
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS11_Echo_StringLiteral(t *testing.T) {
+ require.Equal(t, "hello", renderS1T(t, `{% echo "hello" %}`))
+}
+
+func TestS11_Echo_IntLiteral(t *testing.T) {
+ require.Equal(t, "42", renderS1T(t, `{% echo 42 %}`))
+}
+
+func TestS11_Echo_FloatLiteral(t *testing.T) {
+ require.Equal(t, "3.14", renderS1T(t, `{% echo 3.14 %}`))
+}
+
+func TestS11_Echo_Variable(t *testing.T) {
+ require.Equal(t, "world", renderS1(t, `{% echo v %}`, map[string]any{"v": "world"}))
+}
+
+func TestS11_Echo_NilVariable(t *testing.T) {
+ require.Equal(t, "", renderS1(t, `{% echo v %}`, map[string]any{"v": nil}))
+}
+
+func TestS11_Echo_WithFilter(t *testing.T) {
+ require.Equal(t, "HELLO", renderS1(t, `{% echo v | upcase %}`, map[string]any{"v": "hello"}))
+}
+
+func TestS11_Echo_WithFilterChain(t *testing.T) {
+ require.Equal(t, "WORLD", renderS1(t, `{% echo v | downcase | upcase %}`, map[string]any{"v": "World"}))
+}
+
+func TestS11_Echo_InsideLiquidTag(t *testing.T) {
+ // echo is specifically designed to work inside {% liquid %}
+ src := "{% liquid\necho greeting\necho name\n%}"
+ out := renderS1(t, src, map[string]any{"greeting": "Hi", "name": "there"})
+ require.Equal(t, "Hithere", out)
+}
+
+func TestS11_Echo_EqualToObjectSyntax(t *testing.T) {
+ // {% echo expr %} should produce the same output as {{ expr }}
+ binds := map[string]any{"n": 7}
+ require.Equal(t,
+ renderS1(t, `{{ n }}`, binds),
+ renderS1(t, `{% echo n %}`, binds))
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.2 Variables — assign
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS12_Assign_String(t *testing.T) {
+ require.Equal(t, "hello", renderS1T(t, `{% assign x = "hello" %}{{ x }}`))
+}
+
+func TestS12_Assign_Integer(t *testing.T) {
+ require.Equal(t, "42", renderS1T(t, `{% assign n = 42 %}{{ n }}`))
+}
+
+func TestS12_Assign_Float(t *testing.T) {
+ require.Equal(t, "3.14", renderS1T(t, `{% assign f = 3.14 %}{{ f }}`))
+}
+
+func TestS12_Assign_BoolTrue(t *testing.T) {
+ require.Equal(t, "true", renderS1T(t, `{% assign b = true %}{{ b }}`))
+}
+
+func TestS12_Assign_BoolFalse(t *testing.T) {
+ require.Equal(t, "false", renderS1T(t, `{% assign b = false %}{{ b }}`))
+}
+
+func TestS12_Assign_OverwritesExistingBinding(t *testing.T) {
+ // assign overrides whatever was in the binding
+ out := renderS1(t, `{% assign x = "new" %}{{ x }}`, map[string]any{"x": "old"})
+ require.Equal(t, "new", out)
+}
+
+func TestS12_Assign_FromFilter(t *testing.T) {
+ out := renderS1(t, `{% assign up = name | upcase %}{{ up }}`, map[string]any{"name": "alice"})
+ require.Equal(t, "ALICE", out)
+}
+
+func TestS12_Assign_FromFilterChain(t *testing.T) {
+ out := renderS1(t, `{% assign parts = s | downcase | split: " " %}{{ parts[0] }}-{{ parts[1] }}`,
+ map[string]any{"s": "HELLO WORLD"})
+ require.Equal(t, "hello-world", out)
+}
+
+func TestS12_Assign_Chained(t *testing.T) {
+ // assigning x from a variable y that was also assigned in this template
+ out := renderS1T(t, `{% assign a = "x" %}{% assign b = a %}{% assign a = "y" %}{{ a }}-{{ b }}`)
+ // b captured the value of a at assignment time, not a live reference
+ require.Equal(t, "y-x", out)
+}
+
+func TestS12_Assign_FromExistingBinding(t *testing.T) {
+ out := renderS1(t, `{% assign y = x %}{{ y }}`, map[string]any{"x": "value"})
+ require.Equal(t, "value", out)
+}
+
+func TestS12_Assign_Nil(t *testing.T) {
+ out := renderS1(t, `{% assign v = nil_var %}[{{ v }}]`, map[string]any{"nil_var": nil})
+ require.Equal(t, "[]", out)
+}
+
+func TestS12_Assign_UsableInConditional(t *testing.T) {
+ out := renderS1T(t, `{% assign flag = true %}{% if flag %}yes{% endif %}`)
+ require.Equal(t, "yes", out)
+}
+
+func TestS12_Assign_UsableInLoop(t *testing.T) {
+ // assign a string, split it, iterate the parts
+ out := renderS1T(t, `{% assign words = "a,b,c" | split: "," %}{% for w in words %}{{ w }}{% endfor %}`)
+ require.Equal(t, "abc", out)
+}
+
+func TestS12_Assign_DoesNotLeakAcrossRenders(t *testing.T) {
+ // assign in one render should not affect a separate render
+ eng := liquid.NewEngine()
+ out1, err := eng.ParseAndRenderString(`{% assign secret = "ok" %}{{ secret }}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "ok", out1)
+ out2, err := eng.ParseAndRenderString(`{{ secret }}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "", out2) // no bleed-over
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.2 Variables — capture
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS12_Capture_Basic(t *testing.T) {
+ out := renderS1T(t, `{% capture msg %}hello world{% endcapture %}{{ msg }}`)
+ require.Equal(t, "hello world", out)
+}
+
+func TestS12_Capture_EmptyBlock(t *testing.T) {
+ out := renderS1T(t, `{% capture v %}{% endcapture %}[{{ v }}]`)
+ require.Equal(t, "[]", out)
+}
+
+func TestS12_Capture_PreservesWhitespace(t *testing.T) {
+ out := renderS1T(t, "{% capture v %} spaces {% endcapture %}[{{ v }}]")
+ require.Equal(t, "[ spaces ]", out)
+}
+
+func TestS12_Capture_MultilineContent(t *testing.T) {
+ src := "{% capture block %}\nline1\nline2\n{% endcapture %}[{{ block }}]"
+ out := renderS1T(t, src)
+ require.Equal(t, "[\nline1\nline2\n]", out)
+}
+
+func TestS12_Capture_WithExpressions(t *testing.T) {
+ out := renderS1(t, `{% capture greeting %}Hello, {{ name }}!{% endcapture %}{{ greeting }}`,
+ map[string]any{"name": "Alice"})
+ require.Equal(t, "Hello, Alice!", out)
+}
+
+func TestS12_Capture_WithFilters(t *testing.T) {
+ out := renderS1(t, `{% capture loud %}{{ name | upcase }}{% endcapture %}{{ loud }}`,
+ map[string]any{"name": "alice"})
+ require.Equal(t, "ALICE", out)
+}
+
+func TestS12_Capture_OverwritesPriorValue(t *testing.T) {
+ out := renderS1T(t, `{% capture v %}first{% endcapture %}{% capture v %}second{% endcapture %}{{ v }}`)
+ require.Equal(t, "second", out)
+}
+
+func TestS12_Capture_UsedInConditional(t *testing.T) {
+ src := `{% capture flag %}yes{% endcapture %}{% if flag == "yes" %}match{% endif %}`
+ require.Equal(t, "match", renderS1T(t, src))
+}
+
+func TestS12_Capture_WithLoop(t *testing.T) {
+ src := `{% capture all %}{% for i in arr %}{{ i }}{% endfor %}{% endcapture %}[{{ all }}]`
+ out := renderS1(t, src, map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "[123]", out)
+}
+
+func TestS12_Capture_QuotedVarName_SingleQuote(t *testing.T) {
+ // Bug fix: {% capture 'var' %} should strip quotes from the variable name
+ out := renderS1T(t, `{% capture 'msg' %}quoted{% endcapture %}{{ msg }}`)
+ require.Equal(t, "quoted", out)
+}
+
+func TestS12_Capture_QuotedVarName_DoubleQuote(t *testing.T) {
+ out := renderS1T(t, `{% capture "msg" %}double{% endcapture %}{{ msg }}`)
+ require.Equal(t, "double", out)
+}
+
+func TestS12_Capture_QuotedVarName_AccessibleLikePlain(t *testing.T) {
+ // Quoted and unquoted captures should produce identical results
+ plain := renderS1T(t, `{% capture x %}value{% endcapture %}{{ x }}`)
+ quoted := renderS1T(t, `{% capture 'x' %}value{% endcapture %}{{ x }}`)
+ require.Equal(t, plain, quoted)
+}
+
+func TestS12_Capture_DoesNotLeakAcrossRenders(t *testing.T) {
+ eng := liquid.NewEngine()
+ _, err := eng.ParseAndRenderString(`{% capture x %}captured{% endcapture %}`, nil)
+ require.NoError(t, err)
+ out, err := eng.ParseAndRenderString(`{{ x }}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.3 Conditionals — if / elsif / else
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS13_If_TrueCondition(t *testing.T) {
+ out := renderS1(t, `{% if v %}yes{% endif %}`, map[string]any{"v": true})
+ require.Equal(t, "yes", out)
+}
+
+func TestS13_If_FalseCondition_RendersNothing(t *testing.T) {
+ out := renderS1(t, `{% if v %}yes{% endif %}`, map[string]any{"v": false})
+ require.Equal(t, "", out)
+}
+
+func TestS13_If_NilCondition_RendersElse(t *testing.T) {
+ out := renderS1(t, `{% if v %}yes{% else %}no{% endif %}`, map[string]any{"v": nil})
+ require.Equal(t, "no", out)
+}
+
+func TestS13_If_Else_TrueTakesIf(t *testing.T) {
+ out := renderS1(t, `{% if v %}yes{% else %}no{% endif %}`, map[string]any{"v": true})
+ require.Equal(t, "yes", out)
+}
+
+func TestS13_If_Else_FalseTakesElse(t *testing.T) {
+ out := renderS1(t, `{% if v %}yes{% else %}no{% endif %}`, map[string]any{"v": false})
+ require.Equal(t, "no", out)
+}
+
+func TestS13_If_Elsif_AllBranches(t *testing.T) {
+ tpl := `{% if n == 1 %}one{% elsif n == 2 %}two{% elsif n == 3 %}three{% else %}other{% endif %}`
+ for _, tc := range []struct {
+ n int
+ want string
+ }{
+ {1, "one"}, {2, "two"}, {3, "three"}, {4, "other"},
+ } {
+ tc := tc
+ t.Run(fmt.Sprintf("n=%d", tc.n), func(t *testing.T) {
+ require.Equal(t, tc.want, renderS1(t, tpl, map[string]any{"n": tc.n}))
+ })
+ }
+}
+
+func TestS13_If_ManyElsif(t *testing.T) {
+ // Ensures all elsif branches are checked in order
+ tpl := `{% if n == 1 %}a{% elsif n == 2 %}b{% elsif n == 3 %}c{% elsif n == 4 %}d{% elsif n == 5 %}e{% else %}f{% endif %}`
+ for n := 1; n <= 6; n++ {
+ n := n
+ t.Run(fmt.Sprintf("n=%d", n), func(t *testing.T) {
+ want := string(rune('a' + n - 1))
+ require.Equal(t, want, renderS1(t, tpl, map[string]any{"n": n}))
+ })
+ }
+}
+
+func TestS13_If_And_BothTrue(t *testing.T) {
+ out := renderS1(t, `{% if a and b %}yes{% else %}no{% endif %}`, map[string]any{"a": true, "b": true})
+ require.Equal(t, "yes", out)
+}
+
+func TestS13_If_And_OneFalse(t *testing.T) {
+ out := renderS1(t, `{% if a and b %}yes{% else %}no{% endif %}`, map[string]any{"a": true, "b": false})
+ require.Equal(t, "no", out)
+}
+
+func TestS13_If_Or_OneTrue(t *testing.T) {
+ out := renderS1(t, `{% if a or b %}yes{% else %}no{% endif %}`, map[string]any{"a": false, "b": true})
+ require.Equal(t, "yes", out)
+}
+
+func TestS13_If_Or_BothFalse(t *testing.T) {
+ out := renderS1(t, `{% if a or b %}yes{% else %}no{% endif %}`, map[string]any{"a": false, "b": false})
+ require.Equal(t, "no", out)
+}
+
+func TestS13_If_ComparisonOperators(t *testing.T) {
+ cases := []struct {
+ tpl string
+ want string
+ }{
+ {`{% if 5 == 5 %}ok{% endif %}`, "ok"},
+ {`{% if 5 == 4 %}ok{% else %}no{% endif %}`, "no"},
+ {`{% if 5 != 4 %}ok{% endif %}`, "ok"},
+ {`{% if 5 != 5 %}ok{% else %}no{% endif %}`, "no"},
+ {`{% if 5 > 4 %}ok{% endif %}`, "ok"},
+ {`{% if 4 > 5 %}ok{% else %}no{% endif %}`, "no"},
+ {`{% if 4 < 5 %}ok{% endif %}`, "ok"},
+ {`{% if 5 < 4 %}ok{% else %}no{% endif %}`, "no"},
+ {`{% if 5 >= 5 %}ok{% endif %}`, "ok"},
+ {`{% if 5 >= 6 %}ok{% else %}no{% endif %}`, "no"},
+ {`{% if 4 <= 4 %}ok{% endif %}`, "ok"},
+ {`{% if 5 <= 4 %}ok{% else %}no{% endif %}`, "no"},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.tpl, func(t *testing.T) {
+ require.Equal(t, tc.want, renderS1T(t, tc.tpl))
+ })
+ }
+}
+
+func TestS13_If_Contains_String(t *testing.T) {
+ out := renderS1T(t, `{% if "foobar" contains "oba" %}yes{% else %}no{% endif %}`)
+ require.Equal(t, "yes", out)
+}
+
+func TestS13_If_Contains_String_NoMatch(t *testing.T) {
+ out := renderS1T(t, `{% if "foobar" contains "xyz" %}yes{% else %}no{% endif %}`)
+ require.Equal(t, "no", out)
+}
+
+func TestS13_If_Contains_Array(t *testing.T) {
+ out := renderS1(t, `{% if arr contains "b" %}yes{% else %}no{% endif %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "yes", out)
+}
+
+func TestS13_If_Contains_Array_NoMatch(t *testing.T) {
+ out := renderS1(t, `{% if arr contains "z" %}yes{% else %}no{% endif %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "no", out)
+}
+
+func TestS13_If_Nested(t *testing.T) {
+ tpl := `{% if a %}{% if b %}both{% else %}only_a{% endif %}{% else %}none{% endif %}`
+ require.Equal(t, "both", renderS1(t, tpl, map[string]any{"a": true, "b": true}))
+ require.Equal(t, "only_a", renderS1(t, tpl, map[string]any{"a": true, "b": false}))
+ require.Equal(t, "none", renderS1(t, tpl, map[string]any{"a": false, "b": true}))
+}
+
+func TestS13_If_NestedThreeLevels(t *testing.T) {
+ tpl := `{% if a %}{% if b %}{% if c %}abc{% else %}ab{% endif %}{% else %}a{% endif %}{% else %}none{% endif %}`
+ require.Equal(t, "abc", renderS1(t, tpl, map[string]any{"a": true, "b": true, "c": true}))
+ require.Equal(t, "ab", renderS1(t, tpl, map[string]any{"a": true, "b": true, "c": false}))
+ require.Equal(t, "a", renderS1(t, tpl, map[string]any{"a": true, "b": false, "c": false}))
+ require.Equal(t, "none", renderS1(t, tpl, map[string]any{"a": false, "b": true, "c": true}))
+}
+
+func TestS13_If_WithGoTypedInt(t *testing.T) {
+ // all int-like types should compare correctly against integer literals
+ for _, v := range []any{int8(5), int16(5), int32(5), int64(5), uint(5), uint32(5), uint64(5)} {
+ v := v
+ t.Run(fmt.Sprintf("%T", v), func(t *testing.T) {
+ out := renderS1(t, `{% if n == 5 %}yes{% else %}no{% endif %}`, map[string]any{"n": v})
+ require.Equal(t, "yes", out)
+ })
+ }
+}
+
+func TestS13_If_WithGoTypedFloat(t *testing.T) {
+ for _, v := range []any{float32(5.0), float64(5.0)} {
+ v := v
+ t.Run(fmt.Sprintf("%T", v), func(t *testing.T) {
+ out := renderS1(t, `{% if n == 5 %}yes{% else %}no{% endif %}`, map[string]any{"n": v})
+ require.Equal(t, "yes", out)
+ })
+ }
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.3 Conditionals — unless
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS13_Unless_RendersWhenFalse(t *testing.T) {
+ out := renderS1(t, `{% unless v %}rendered{% endunless %}`, map[string]any{"v": false})
+ require.Equal(t, "rendered", out)
+}
+
+func TestS13_Unless_SkipsWhenTrue(t *testing.T) {
+ out := renderS1(t, `{% unless v %}rendered{% endunless %}`, map[string]any{"v": true})
+ require.Equal(t, "", out)
+}
+
+func TestS13_Unless_RendersWhenNil(t *testing.T) {
+ out := renderS1(t, `{% unless v %}rendered{% endunless %}`, map[string]any{"v": nil})
+ require.Equal(t, "rendered", out)
+}
+
+func TestS13_Unless_WithElse_FalseTakesBody(t *testing.T) {
+ src := `{% unless v %}body{% else %}elsebranch{% endunless %}`
+ require.Equal(t, "body", renderS1(t, src, map[string]any{"v": false}))
+}
+
+func TestS13_Unless_WithElse_TrueTakesElse(t *testing.T) {
+ src := `{% unless v %}body{% else %}elsebranch{% endunless %}`
+ require.Equal(t, "elsebranch", renderS1(t, src, map[string]any{"v": true}))
+}
+
+func TestS13_Unless_ComplexCondition(t *testing.T) {
+ // unless a == b evaluates as: not (a == b)
+ out := renderS1(t, `{% unless a == b %}different{% else %}same{% endunless %}`,
+ map[string]any{"a": 1, "b": 2})
+ require.Equal(t, "different", out)
+}
+
+func TestS13_Unless_ComplexCondition_Equal(t *testing.T) {
+ out := renderS1(t, `{% unless a == b %}different{% else %}same{% endunless %}`,
+ map[string]any{"a": 5, "b": 5})
+ require.Equal(t, "same", out)
+}
+
+func TestS13_Unless_Nested(t *testing.T) {
+ src := `{% unless skip %}{% unless also_skip %}shown{% endunless %}{% endunless %}`
+ require.Equal(t, "shown", renderS1(t, src, map[string]any{"skip": false, "also_skip": false}))
+ require.Equal(t, "", renderS1(t, src, map[string]any{"skip": true, "also_skip": false}))
+ require.Equal(t, "", renderS1(t, src, map[string]any{"skip": false, "also_skip": true}))
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.3 Conditionals — case / when
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS13_Case_BasicStringMatch(t *testing.T) {
+ out := renderS1(t, `{% case x %}{% when "hello" %}hi{% endcase %}`, map[string]any{"x": "hello"})
+ require.Equal(t, "hi", out)
+}
+
+func TestS13_Case_NoMatchRendersEmpty(t *testing.T) {
+ out := renderS1(t, `{% case x %}{% when "hello" %}hi{% endcase %}`, map[string]any{"x": "bye"})
+ require.Equal(t, "", out)
+}
+
+func TestS13_Case_BasicIntMatch(t *testing.T) {
+ out := renderS1(t, `{% case n %}{% when 1 %}one{% when 2 %}two{% endcase %}`, map[string]any{"n": 2})
+ require.Equal(t, "two", out)
+}
+
+func TestS13_Case_ElseBranch(t *testing.T) {
+ out := renderS1(t, `{% case n %}{% when 1 %}one{% else %}other{% endcase %}`, map[string]any{"n": 99})
+ require.Equal(t, "other", out)
+}
+
+func TestS13_Case_OrSyntax(t *testing.T) {
+ // when "a" or "b" should match either value
+ tpl := `{% case x %}{% when "a" or "b" %}match{% else %}nope{% endcase %}`
+ require.Equal(t, "match", renderS1(t, tpl, map[string]any{"x": "a"}))
+ require.Equal(t, "match", renderS1(t, tpl, map[string]any{"x": "b"}))
+ require.Equal(t, "nope", renderS1(t, tpl, map[string]any{"x": "c"}))
+}
+
+func TestS13_Case_MultipleWhenClauses(t *testing.T) {
+ tpl := `{% case x %}{% when "a" %}A{% when "b" %}B{% when "c" %}C{% else %}?{% endcase %}`
+ for _, tc := range []struct{ x, want string }{
+ {"a", "A"}, {"b", "B"}, {"c", "C"}, {"d", "?"},
+ } {
+ tc := tc
+ t.Run(tc.x, func(t *testing.T) {
+ require.Equal(t, tc.want, renderS1(t, tpl, map[string]any{"x": tc.x}))
+ })
+ }
+}
+
+func TestS13_Case_NilInputFallsToElse(t *testing.T) {
+ out := renderS1(t, `{% case x %}{% when "something" %}hit{% else %}miss{% endcase %}`,
+ map[string]any{"x": nil})
+ require.Equal(t, "miss", out)
+}
+
+func TestS13_Case_BooleanMatch(t *testing.T) {
+ tpl := `{% case b %}{% when true %}yes{% when false %}no{% endcase %}`
+ require.Equal(t, "yes", renderS1(t, tpl, map[string]any{"b": true}))
+ require.Equal(t, "no", renderS1(t, tpl, map[string]any{"b": false}))
+}
+
+func TestS13_Case_WithGoTypedInt(t *testing.T) {
+ // Go int types should match integer literals
+ tpl := `{% case n %}{% when 7 %}seven{% else %}other{% endcase %}`
+ for _, v := range []any{int(7), int32(7), int64(7), uint(7), uint64(7)} {
+ v := v
+ t.Run(fmt.Sprintf("%T", v), func(t *testing.T) {
+ require.Equal(t, "seven", renderS1(t, tpl, map[string]any{"n": v}))
+ })
+ }
+}
+
+func TestS13_Case_VariableSubjectAndWhen(t *testing.T) {
+ // Both subject and when-value can be variables
+ out := renderS1(t, `{% case x %}{% when a %}match{% else %}no{% endcase %}`,
+ map[string]any{"x": "hello", "a": "hello"})
+ require.Equal(t, "match", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — for / else / endfor (basic)
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_For_BasicStringArray(t *testing.T) {
+ out := renderS1(t, `{% for s in words %}[{{ s }}]{% endfor %}`,
+ map[string]any{"words": []string{"a", "b", "c"}})
+ require.Equal(t, "[a][b][c]", out)
+}
+
+func TestS14_For_BasicIntArray(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{{ i }} {% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "1 2 3 ", out)
+}
+
+func TestS14_For_IntRange(t *testing.T) {
+ out := renderS1T(t, `{% for i in (1..5) %}{{ i }}{% endfor %}`)
+ require.Equal(t, "12345", out)
+}
+
+func TestS14_For_RangeViaVariables(t *testing.T) {
+ out := renderS1(t, `{% for i in (start..stop) %}{{ i }} {% endfor %}`,
+ map[string]any{"start": 3, "stop": 6})
+ require.Equal(t, "3 4 5 6 ", out)
+}
+
+func TestS14_For_Else_EmptyArrayRendersElse(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{{ i }}{% else %}empty{% endfor %}`,
+ map[string]any{"arr": []int{}})
+ require.Equal(t, "empty", out)
+}
+
+func TestS14_For_Else_NilCollectionRendersElse(t *testing.T) {
+ // Bug fix: nil collection should render else branch, not just empty string
+ out := renderS1(t, `{% for i in arr %}{{ i }}{% else %}nil_else{% endfor %}`,
+ map[string]any{"arr": nil})
+ require.Equal(t, "nil_else", out)
+}
+
+func TestS14_For_Else_NonEmptySkipsElse(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{{ i }}{% else %}empty{% endfor %}`,
+ map[string]any{"arr": []int{1, 2}})
+ require.Equal(t, "12", out)
+}
+
+func TestS14_For_OverMap(t *testing.T) {
+ // Iterating a map with a single known key
+ out := renderS1(t, `{% for pair in m %}{{ pair[0] }}={{ pair[1] }}{% endfor %}`,
+ map[string]any{"m": map[string]any{"k": "v"}})
+ require.Equal(t, "k=v", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — for modifiers
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_For_Limit(t *testing.T) {
+ out := renderS1(t, `{% for i in arr limit:2 %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{10, 20, 30, 40}})
+ require.Equal(t, "1020", out)
+}
+
+func TestS14_For_Limit_Zero_RendersElse(t *testing.T) {
+ out := renderS1(t, `{% for i in arr limit:0 %}{{ i }}{% else %}none{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "none", out)
+}
+
+func TestS14_For_Offset(t *testing.T) {
+ out := renderS1(t, `{% for i in arr offset:2 %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{10, 20, 30, 40}})
+ require.Equal(t, "3040", out)
+}
+
+func TestS14_For_Offset_PastEnd_RendersElse(t *testing.T) {
+ out := renderS1(t, `{% for i in arr offset:10 %}{{ i }}{% else %}none{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "none", out)
+}
+
+func TestS14_For_Reversed(t *testing.T) {
+ out := renderS1(t, `{% for i in arr reversed %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "321", out)
+}
+
+func TestS14_For_Reversed_SingleElement(t *testing.T) {
+ out := renderS1(t, `{% for i in arr reversed %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{42}})
+ require.Equal(t, "42", out)
+}
+
+func TestS14_For_Limit_And_Offset(t *testing.T) {
+ // offset:1 limit:2 → skip 1 → take 2 → [20, 30]
+ out := renderS1(t, `{% for i in arr limit:2 offset:1 %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{10, 20, 30, 40}})
+ require.Equal(t, "2030", out)
+}
+
+func TestS14_For_AllModifiers_OffsetLimitReversed(t *testing.T) {
+ // Ruby order: ALWAYS offset → limit → reversed, regardless of syntax order.
+ // arr=[1,2,3,4,5]: offset:1=[2,3,4,5]; limit:3=[2,3,4]; reversed=[4,3,2]
+ arr := map[string]any{"arr": []int{1, 2, 3, 4, 5}}
+ want := "432"
+ cases := []string{
+ `{% for i in arr offset:1 limit:3 reversed %}{{ i }}{% endfor %}`,
+ `{% for i in arr reversed offset:1 limit:3 %}{{ i }}{% endfor %}`,
+ `{% for i in arr limit:3 reversed offset:1 %}{{ i }}{% endfor %}`,
+ `{% for i in arr reversed limit:3 offset:1 %}{{ i }}{% endfor %}`,
+ }
+ for _, tpl := range cases {
+ tpl := tpl
+ t.Run(tpl, func(t *testing.T) {
+ require.Equal(t, want, renderS1(t, tpl, arr))
+ })
+ }
+}
+
+func TestS14_For_Modifier_ReversedLimitOne(t *testing.T) {
+ // arr=[first,second,third]; offset:0; limit:1=[first]; reversed=[first]
+ out := renderS1(t, `{% for a in array reversed limit:1 %}{{ a }}{% endfor %}`,
+ map[string]any{"array": []string{"first", "second", "third"}})
+ require.Equal(t, "first", out)
+}
+
+func TestS14_For_Modifier_ReversedOffsetOne(t *testing.T) {
+ // arr=[first,second,third]; offset:1=[second,third]; reversed=[third,second]
+ out := renderS1(t, `{% for a in array reversed offset:1 %}{{ a }}.{% endfor %}`,
+ map[string]any{"array": []string{"first", "second", "third"}})
+ require.Equal(t, "third.second.", out)
+}
+
+func TestS14_For_LimitFromVariable(t *testing.T) {
+ out := renderS1(t, `{% for i in arr limit:n %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4}, "n": 2})
+ require.Equal(t, "12", out)
+}
+
+func TestS14_For_OffsetFromVariable(t *testing.T) {
+ out := renderS1(t, `{% for i in arr offset:n %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4}, "n": 2})
+ require.Equal(t, "34", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — forloop variables
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_Forloop_Index(t *testing.T) {
+ // forloop.index is 1-based
+ out := renderS1(t, `{% for i in arr %}{{ forloop.index }}{% endfor %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "123", out)
+}
+
+func TestS14_Forloop_Index0(t *testing.T) {
+ // forloop.index0 is 0-based
+ out := renderS1(t, `{% for i in arr %}{{ forloop.index0 }}{% endfor %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "012", out)
+}
+
+func TestS14_Forloop_First(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{% if forloop.first %}F{% endif %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "Fabc", out)
+}
+
+func TestS14_Forloop_Last(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{{ i }}{% if forloop.last %}L{% endif %}{% endfor %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "abcL", out)
+}
+
+func TestS14_Forloop_SingleElement_FirstAndLast(t *testing.T) {
+ // With a single element, both first and last should be true
+ out := renderS1(t, `{% for i in arr %}{% if forloop.first %}F{% endif %}{% if forloop.last %}L{% endif %}{% endfor %}`,
+ map[string]any{"arr": []string{"only"}})
+ require.Equal(t, "FL", out)
+}
+
+func TestS14_Forloop_Length(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{{ forloop.length }} {% endfor %}`,
+ map[string]any{"arr": []int{10, 20, 30}})
+ // length stays constant throughout all iterations
+ require.Equal(t, "3 3 3 ", out)
+}
+
+func TestS14_Forloop_Rindex(t *testing.T) {
+ // rindex: items remaining including current (last item = 1)
+ out := renderS1(t, `{% for i in arr %}{{ forloop.rindex }}{% endfor %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "321", out)
+}
+
+func TestS14_Forloop_Rindex0(t *testing.T) {
+ // rindex0: items remaining after current (last item = 0)
+ out := renderS1(t, `{% for i in arr %}{{ forloop.rindex0 }}{% endfor %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "210", out)
+}
+
+func TestS14_Forloop_Name(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{{ forloop.name }}{% endfor %}`,
+ map[string]any{"arr": []string{"x"}})
+ // forloop.name is "variable-collection" format
+ require.Equal(t, "i-arr", out)
+}
+
+func TestS14_Forloop_Nested_IndependentVars(t *testing.T) {
+ // Each nested for-loop has its own forloop variables that reset
+ src := `{% for i in outer %}{% for j in inner %}{{ forloop.index }}{% endfor %}|{% endfor %}`
+ out := renderS1(t, src, map[string]any{
+ "outer": []string{"a", "b"},
+ "inner": []string{"x", "y", "z"},
+ })
+ require.Equal(t, "123|123|", out)
+}
+
+func TestS14_Forloop_Nested_Length(t *testing.T) {
+ // Inner length reflects inner array, outer length reflects outer array
+ src := `{% for i in outer %}O{{ forloop.length }}{% for j in inner %}I{{ forloop.length }}{% endfor %}{% endfor %}`
+ out := renderS1(t, src, map[string]any{
+ "outer": []int{1, 2},
+ "inner": []int{10, 20, 30},
+ })
+ require.Equal(t, "O2I3I3I3O2I3I3I3", out)
+}
+
+func TestS14_Forloop_ParentLoop(t *testing.T) {
+ // forloop.parentloop gives access to the outer loop's forloop map
+ src := `{% for i in outer %}{% for j in inner %}{{ forloop.parentloop.index }}-{{ forloop.index }} {% endfor %}{% endfor %}`
+ out := renderS1(t, src, map[string]any{
+ "outer": []string{"a", "b"},
+ "inner": []string{"x", "y"},
+ })
+ require.Equal(t, "1-1 1-2 2-1 2-2 ", out)
+}
+
+func TestS14_Forloop_AllFieldsPresent(t *testing.T) {
+ // Verify all standard forloop fields are accessible without error
+ src := `{% for i in arr %}{{ forloop.index }},{{ forloop.index0 }},{{ forloop.rindex }},{{ forloop.rindex0 }},{{ forloop.first }},{{ forloop.last }},{{ forloop.length }}{% endfor %}`
+ out := renderS1(t, src, map[string]any{"arr": []int{1}})
+ require.Equal(t, "1,0,1,0,true,true,1", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — break / continue
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_Break_StopsLoop(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4, 5}})
+ require.Equal(t, "12", out)
+}
+
+func TestS14_Break_OnFirstIteration(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{% break %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "", out)
+}
+
+func TestS14_Break_OnLastIteration(t *testing.T) {
+ // break at the last item — everything before it is still rendered
+ out := renderS1(t, `{% for i in arr %}{% if forloop.last %}{% break %}{% endif %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "12", out)
+}
+
+func TestS14_Break_OnlyExitsInnerLoop(t *testing.T) {
+ // break in inner loop should not affect the outer loop
+ src := `{% for i in outer %}{{ i }}{% for j in inner %}{% if j == 2 %}{% break %}{% endif %}{{ j }}{% endfor %}{% endfor %}`
+ out := renderS1(t, src, map[string]any{
+ "outer": []int{1, 2},
+ "inner": []int{1, 2, 3},
+ })
+ // i=1→"1", inner j=1→"1" j=2→break; i=2→"2", inner j=1→"1" j=2→break → "1121"
+ require.Equal(t, "1121", out)
+}
+
+func TestS14_Continue_SkipsCurrentIteration(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{% if i == 2 %}{% continue %}{% endif %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4}})
+ require.Equal(t, "134", out)
+}
+
+func TestS14_Continue_SkipsRestOfIterationBody(t *testing.T) {
+ // everything after continue in the same iteration should be skipped
+ out := renderS1(t, `{% for i in arr %}{% if i == 2 %}{% continue %}{% endif %}{{ i }}-{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "1-3-", out)
+}
+
+func TestS14_Continue_AllSkipped(t *testing.T) {
+ // if every iteration hits continue, result is empty
+ out := renderS1(t, `{% for i in arr %}{% continue %}{{ i }}{% endfor %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, "", out)
+}
+
+func TestS14_Continue_OnlyAffectsInnerLoop(t *testing.T) {
+ // continue in inner loop should not affect the outer loop
+ src := `{% for i in outer %}|{% for j in inner %}{% if j == 2 %}{% continue %}{% endif %}{{ j }}{% endfor %}{% endfor %}`
+ out := renderS1(t, src, map[string]any{
+ "outer": []int{1, 2},
+ "inner": []int{1, 2, 3},
+ })
+ require.Equal(t, "|13|13", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — offset:continue
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_OffsetContinue_Basic(t *testing.T) {
+ // First loop takes items 0-1; second continues from item 2
+ arr := map[string]any{"arr": []int{1, 2, 3, 4, 5, 6}}
+ src := `{% for i in arr limit:2 %}{{ i }}{% endfor %};{% for i in arr limit:2 offset:continue %}{{ i }}{% endfor %}`
+ out := renderS1(t, src, arr)
+ require.Equal(t, "12;34", out)
+}
+
+func TestS14_OffsetContinue_ThreeChunks(t *testing.T) {
+ arr := map[string]any{"arr": []int{1, 2, 3, 4, 5, 6}}
+ src := `{% for i in arr limit:2 %}{{ i }}{% endfor %};` +
+ `{% for i in arr limit:2 offset:continue %}{{ i }}{% endfor %};` +
+ `{% for i in arr limit:2 offset:continue %}{{ i }}{% endfor %}`
+ out := renderS1(t, src, arr)
+ require.Equal(t, "12;34;56", out)
+}
+
+func TestS14_OffsetContinue_ExhaustedCollectionRendersEmpty(t *testing.T) {
+ // When offset:continue resumes past the end of the collection, the loop
+ // body and else branch are both skipped — the tag simply emits nothing.
+ arr := map[string]any{"arr": []int{1, 2}}
+ src := `{% for i in arr limit:10 %}{{ i }}{% endfor %};{% for i in arr offset:continue %}{{ i }}{% else %}done{% endfor %}`
+ out := renderS1(t, src, arr)
+ require.Equal(t, "12;", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — cycle
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_Cycle_TwoValues(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{% cycle "even", "odd" %}{% endfor %}`,
+ map[string]any{"arr": make([]int, 4)})
+ require.Equal(t, "evenoddevenodd", out)
+}
+
+func TestS14_Cycle_ThreeValues(t *testing.T) {
+ out := renderS1(t, `{% for i in arr %}{% cycle "a", "b", "c" %}{% endfor %}`,
+ map[string]any{"arr": make([]int, 5)})
+ require.Equal(t, "abcab", out)
+}
+
+func TestS14_Cycle_WrapsAround(t *testing.T) {
+ // 6 iterations with 3-value cycle → exactly 2 complete cycles
+ out := renderS1(t, `{% for i in arr %}{% cycle "x", "y", "z" %}{% endfor %}`,
+ map[string]any{"arr": make([]int, 6)})
+ require.Equal(t, "xyzxyz", out)
+}
+
+func TestS14_Cycle_NamedGroups_Independent(t *testing.T) {
+ // Two cycle groups with different names cycle independently
+ src := `{% for i in arr %}{% cycle "g1": "a", "b" %}-{% cycle "g2": "x", "y", "z" %}|{% endfor %}`
+ out := renderS1(t, src, map[string]any{"arr": make([]int, 3)})
+ require.Equal(t, "a-x|b-y|a-z|", out)
+}
+
+func TestS14_Cycle_NamedGroups_SameValuesStillIndependent(t *testing.T) {
+ // Even with same values, two groups cycle independently
+ src := `{% for i in arr %}{% cycle "first": "1", "2" %} {% cycle "second": "1", "2" %}|{% endfor %}`
+ out := renderS1(t, src, map[string]any{"arr": make([]int, 3)})
+ require.Equal(t, "1 1|2 2|1 1|", out)
+}
+
+func TestS14_Cycle_ResetsOnNewRender(t *testing.T) {
+ // Each new render starts the cycle from the beginning
+ eng := liquid.NewEngine()
+ renderCycle := func() string {
+ out, err := eng.ParseAndRenderString(
+ `{% for i in arr %}{% cycle "a","b","c" %}{% endfor %}`,
+ map[string]any{"arr": make([]int, 3)})
+ require.NoError(t, err)
+ return out
+ }
+ require.Equal(t, "abc", renderCycle())
+ require.Equal(t, "abc", renderCycle()) // must reset each time
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1.4 Iteration — tablerow
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS14_Tablerow_ProducesValidHTMLStructure(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr %}{{ i }}{% endtablerow %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Contains(t, out, ``)
+ require.Contains(t, out, ``)
+ require.Contains(t, out, " ")
+ require.Contains(t, out, " ")
+}
+
+func TestS14_Tablerow_NoColsAllOnOneRow(t *testing.T) {
+ // Without cols, all items go in a single row
+ out := renderS1(t, `{% tablerow i in arr %}{{ i }}{% endtablerow %}`,
+ map[string]any{"arr": []int{1, 2, 3}})
+ require.Equal(t, 1, strings.Count(out, "")
+ require.Equal(t, 3, strings.Count(out, " elements")
+}
+
+func TestS14_Tablerow_WithCols_TwoRows(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr cols:2 %}{{ i }}{% endtablerow %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4}})
+ require.Equal(t, 2, strings.Count(out, " `)
+ require.Contains(t, out, ` `)
+}
+
+func TestS14_Tablerow_WithCols_ColClassNumbers(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr cols:2 %}{{ i }}{% endtablerow %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4}})
+ require.Contains(t, out, ``)
+ require.Contains(t, out, ` `)
+}
+
+func TestS14_Tablerow_ForloopIndex(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr %}{{ forloop.index }} {% endtablerow %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Contains(t, out, "1 ")
+ require.Contains(t, out, "2 ")
+ require.Contains(t, out, "3 ")
+}
+
+func TestS14_Tablerow_ForloopFirst_Last(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr %}{% if forloop.first %}F{% endif %}{{ i }}{% if forloop.last %}L{% endif %}{% endtablerow %}`,
+ map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Contains(t, out, "Fx")
+ require.Contains(t, out, "zL")
+}
+
+func TestS14_Tablerow_ColVariables(t *testing.T) {
+ // forloop.col is 1-based column index; col_first and col_last for boundary detection
+ out := renderS1(t, `{% tablerow i in arr cols:2 %}{{ forloop.col }}{% endtablerow %}`,
+ map[string]any{"arr": []int{1, 2, 3, 4}})
+ // pattern: col1,col2,col1,col2 embedded in td content
+ require.Equal(t, 2, strings.Count(out, ">1<"), "expected 2 col-1 cells")
+ require.Equal(t, 2, strings.Count(out, ">2<"), "expected 2 col-2 cells")
+}
+
+func TestS14_Tablerow_WithLimit(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr limit:2 %}{{ i }}{% endtablerow %}`,
+ map[string]any{"arr": []int{10, 20, 30, 40}})
+ require.Contains(t, out, "10")
+ require.Contains(t, out, "20")
+ require.NotContains(t, out, "30")
+ require.NotContains(t, out, "40")
+}
+
+func TestS14_Tablerow_WithOffset(t *testing.T) {
+ out := renderS1(t, `{% tablerow i in arr offset:2 %}{{ i }}{% endtablerow %}`,
+ map[string]any{"arr": []int{10, 20, 30, 40}})
+ require.NotContains(t, out, "10")
+ require.NotContains(t, out, "20")
+ require.Contains(t, out, "30")
+ require.Contains(t, out, "40")
+}
+
+func TestS14_Tablerow_Range(t *testing.T) {
+ out := renderS1T(t, `{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}`)
+ require.Contains(t, out, "1")
+ require.Contains(t, out, "2")
+ require.Contains(t, out, "3")
+ require.Equal(t, 3, strings.Count(out, " text", `{{ word | prepend: "" | append: " " }}`,
+ map[string]any{"word": "text"})
+}
+
+// ── A4. remove / remove_first / remove_last ──────────────────────────────────
+
+func TestS2_Remove_AllOccurrences(t *testing.T) {
+ assert.Equal(t, "The cat sat on the mat", s2plain(t,
+ `{{ "The r cat r sat on r the mat" | remove: "r " }}`))
+}
+
+func TestS2_Remove_NotFound(t *testing.T) {
+ assert.Equal(t, "abc", s2plain(t, `{{ "abc" | remove: "z" }}`))
+}
+
+func TestS2_RemoveFirst_OnlyFirst(t *testing.T) {
+ assert.Equal(t, "The cat sat on the rat mat", s2plain(t,
+ `{{ "The rat cat sat on the rat mat" | remove_first: "rat " }}`))
+}
+
+func TestS2_RemoveLast_OnlyLast(t *testing.T) {
+ assert.Equal(t, "The rat cat sat on the mat", s2plain(t,
+ `{{ "The rat cat sat on the rat mat" | remove_last: " rat" }}`))
+}
+
+func TestS2_Remove_InTemplate(t *testing.T) {
+ s2eq(t, "hello", `{{ s | remove: " world" }}`, map[string]any{"s": "hello world"})
+}
+
+// ── A5. replace / replace_first / replace_last ──────────────────────────────
+
+func TestS2_Replace_AllOccurrences(t *testing.T) {
+ assert.Equal(t, "1, 1, 1", s2plain(t, `{{ "1, 2, 3" | replace: "2", "1" | replace: "3", "1" }}`))
+}
+
+func TestS2_Replace_NotFound(t *testing.T) {
+ assert.Equal(t, "abc", s2plain(t, `{{ "abc" | replace: "z", "x" }}`))
+}
+
+func TestS2_ReplaceFirst_OnlyFirst(t *testing.T) {
+ // replace_first replaces the very first occurrence
+ assert.Equal(t, "2, 1, 3", s2plain(t, `{{ "1, 1, 3" | replace_first: "1", "2" }}`))
+}
+
+func TestS2_ReplaceLast_OnlyLast(t *testing.T) {
+ assert.Equal(t, "1, 1, 2", s2plain(t, `{{ "1, 1, 1" | replace_last: "1", "2" }}`))
+}
+
+func TestS2_Replace_InAssign(t *testing.T) {
+ assert.Equal(t, "Hello Liquid", s2plain(t,
+ `{% assign s = "Hello World" | replace: "World", "Liquid" %}{{ s }}`))
+}
+
+// ── A6. split ────────────────────────────────────────────────────────────────
+
+func TestS2_Split_Basic(t *testing.T) {
+ assert.Equal(t, "foo bar baz", s2plain(t, `{{ "foo,bar,baz" | split: "," | join: " " }}`))
+}
+
+func TestS2_Split_MultiCharSep(t *testing.T) {
+ // "A? ~ ~ ~ ,Z".split("~ ~ ~") = ["A? ", " ,Z"]; join(" ") = "A? ,Z" (3 spaces: trailing + sep + leading)
+ assert.Equal(t, "A? ,Z", s2plain(t, `{{ "A? ~ ~ ~ ,Z" | split: "~ ~ ~" | join: " " }}`))
+}
+
+func TestS2_Split_NoSepFound(t *testing.T) {
+ // When separator not found, returns array with the whole string
+ assert.Equal(t, "1", s2plain(t, `{{ "abc" | split: "~" | size }}`))
+}
+
+func TestS2_Split_TrailingEmptyStringsRemoved(t *testing.T) {
+ // Ruby removes trailing empty strings after split
+ assert.Equal(t, "2", s2plain(t, `{{ "zebra,octopus,,,," | split: "," | size }}`))
+}
+
+func TestS2_Split_ThenFirst(t *testing.T) {
+ assert.Equal(t, "one", s2plain(t, `{{ "one two three" | split: " " | first }}`))
+}
+
+func TestS2_Split_ThenLast(t *testing.T) {
+ assert.Equal(t, "three", s2plain(t, `{{ "one two three" | split: " " | last }}`))
+}
+
+func TestS2_Split_ThenReverse(t *testing.T) {
+ assert.Equal(t, "c b a", s2plain(t, `{{ "a b c" | split: " " | reverse | join: " " }}`))
+}
+
+func TestS2_Split_InForLoop(t *testing.T) {
+ // split result can be iterated in a for loop
+ out := s2plain(t, `{% for w in "one,two,three" | split: "," %}<{{ w }}>{% endfor %}`)
+ assert.Equal(t, "", out)
+}
+
+// ── A7. strip / lstrip / rstrip ──────────────────────────────────────────────
+
+func TestS2_Strip_RemovesBothSides(t *testing.T) {
+ assert.Equal(t, "hello", s2plain(t, `{{ " hello " | strip }}`))
+}
+
+func TestS2_Lstrip_RemovesLeft(t *testing.T) {
+ assert.Equal(t, "hello ", s2plain(t, `{{ " hello " | lstrip }}`))
+}
+
+func TestS2_Rstrip_RemovesRight(t *testing.T) {
+ assert.Equal(t, " hello", s2plain(t, `{{ " hello " | rstrip }}`))
+}
+
+func TestS2_Strip_Tabs(t *testing.T) {
+ assert.Equal(t, "x", s2plain(t, `{{ "\t x \t" | strip }}`))
+}
+
+func TestS2_Strip_Newlines(t *testing.T) {
+ assert.Equal(t, "x", s2plain(t, `{{ "\nx\n" | strip }}`))
+}
+
+func TestS2_Strip_AlreadyClean(t *testing.T) {
+ assert.Equal(t, "clean", s2plain(t, `{{ "clean" | strip }}`))
+}
+
+func TestS2_Strip_Empty(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "" | strip }}`))
+}
+
+// ── A8. strip_html ───────────────────────────────────────────────────────────
+
+func TestS2_StripHtml_BasicTags(t *testing.T) {
+ assert.Equal(t, "Hello World", s2plain(t, `{{ "Hello World
" | strip_html }}`))
+}
+
+func TestS2_StripHtml_ScriptTagWithContent(t *testing.T) {
+ // is removed including content; surrounding spaces preserved
+ assert.Equal(t, "before after", s2plain(t,
+ `{{ "before after" | strip_html }}`))
+}
+
+func TestS2_StripHtml_StyleTagWithContent(t *testing.T) {
+ assert.Equal(t, "visible", s2plain(t,
+ `{{ "visible" | strip_html }}`))
+}
+
+func TestS2_StripHtml_HtmlComment(t *testing.T) {
+ assert.Equal(t, "visible", s2plain(t,
+ `{{ "visible" | strip_html }}`))
+}
+
+func TestS2_StripHtml_CaseInsensitiveScript(t *testing.T) {
+ assert.Equal(t, "clean", s2plain(t,
+ `{{ "clean" | strip_html }}`))
+}
+
+func TestS2_StripHtml_EmptyString(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "" | strip_html }}`))
+}
+
+func TestS2_StripHtml_NoTags(t *testing.T) {
+ assert.Equal(t, "plain text", s2plain(t, `{{ "plain text" | strip_html }}`))
+}
+
+// ── A9. strip_newlines ───────────────────────────────────────────────────────
+
+func TestS2_StripNewlines_UnixLineEndings(t *testing.T) {
+ s2eq(t, "abc", `{{ s | strip_newlines }}`, map[string]any{"s": "a\nb\nc"})
+}
+
+func TestS2_StripNewlines_WindowsLineEndings(t *testing.T) {
+ // \r\n must also be stripped — regression guard for the fix
+ s2eq(t, "abc", `{{ s | strip_newlines }}`, map[string]any{"s": "a\r\nb\r\nc"})
+}
+
+func TestS2_StripNewlines_StandaloneCarriageReturn(t *testing.T) {
+ s2eq(t, "abc", `{{ s | strip_newlines }}`, map[string]any{"s": "a\rb\rc"})
+}
+
+func TestS2_StripNewlines_Mixed(t *testing.T) {
+ s2eq(t, "abcd", `{{ s | strip_newlines }}`, map[string]any{"s": "a\nb\r\nc\rd"})
+}
+
+func TestS2_StripNewlines_NoNewlines(t *testing.T) {
+ assert.Equal(t, "hello", s2plain(t, `{{ "hello" | strip_newlines }}`))
+}
+
+func TestS2_StripNewlines_EmptyResult(t *testing.T) {
+ s2eq(t, "", `{{ s | strip_newlines }}`, map[string]any{"s": "\n\r\n\r"})
+}
+
+// ── A10. newline_to_br ───────────────────────────────────────────────────────
+
+func TestS2_NewlineToBr_Basic(t *testing.T) {
+ s2eq(t, "a \nb \nc", `{{ s | newline_to_br }}`,
+ map[string]any{"s": "a\nb\nc"})
+}
+
+func TestS2_NewlineToBr_WindowsLineEndings(t *testing.T) {
+ // \r\n → \n (not double ) — regression guard
+ s2eq(t, "a \nb \nc", `{{ s | newline_to_br }}`,
+ map[string]any{"s": "a\r\nb\r\nc"})
+}
+
+func TestS2_NewlineToBr_PreservesNewlineAfterBr(t *testing.T) {
+ // The newline after must exist for HTML block formatting
+ s2eq(t, "line1 \nline2", `{{ s | newline_to_br }}`,
+ map[string]any{"s": "line1\nline2"})
+}
+
+func TestS2_NewlineToBr_EmptyString(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "" | newline_to_br }}`))
+}
+
+func TestS2_NewlineToBr_NoNewlines(t *testing.T) {
+ assert.Equal(t, "hello", s2plain(t, `{{ "hello" | newline_to_br }}`))
+}
+
+// ── A11. truncate ────────────────────────────────────────────────────────────
+
+func TestS2_Truncate_Basic(t *testing.T) {
+ assert.Equal(t, "1234...", s2plain(t, `{{ "1234567890" | truncate: 7 }}`))
+}
+
+func TestS2_Truncate_StringShorterThanLimit(t *testing.T) {
+ // String that fits entirely — no truncation
+ assert.Equal(t, "1234567890", s2plain(t, `{{ "1234567890" | truncate: 20 }}`))
+}
+
+func TestS2_Truncate_ExactFit(t *testing.T) {
+ // String whose length == n — must NOT be truncated — regression guard
+ assert.Equal(t, "12345", s2plain(t, `{{ "12345" | truncate: 5 }}`))
+}
+
+func TestS2_Truncate_LimitSmallerThanEllipsis(t *testing.T) {
+ // n < len(ellipsis="...") → return just the ellipsis — regression guard
+ assert.Equal(t, "...", s2plain(t, `{{ "1234567890" | truncate: 0 }}`))
+ assert.Equal(t, "...", s2plain(t, `{{ "1234567890" | truncate: 2 }}`))
+}
+
+func TestS2_Truncate_CustomEllipsis(t *testing.T) {
+ assert.Equal(t, "Ground control, and so on", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncate: 25, ", and so on" }}`))
+}
+
+func TestS2_Truncate_EmptyEllipsis(t *testing.T) {
+ assert.Equal(t, "Ground control to Ma", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncate: 20, "" }}`))
+}
+
+func TestS2_Truncate_Unicode(t *testing.T) {
+ // truncate counts Unicode runes, not bytes
+ assert.Equal(t, "测试...", s2plain(t, `{{ "测试测试测试测试" | truncate: 5 }}`))
+}
+
+func TestS2_Truncate_InAssign(t *testing.T) {
+ assert.Equal(t, "Ground control to...", s2plain(t,
+ `{% assign s = "Ground control to Major Tom." | truncate: 20 %}{{ s }}`))
+}
+
+// ── A12. truncatewords ───────────────────────────────────────────────────────
+
+func TestS2_TruncateWords_MoreWordsThanLimit(t *testing.T) {
+ assert.Equal(t, "one two...", s2plain(t, `{{ "one two three" | truncatewords: 2 }}`))
+}
+
+func TestS2_TruncateWords_FewerWordsThanLimit(t *testing.T) {
+ // String has fewer words than n → return unchanged — no ellipsis
+ assert.Equal(t, "one two three", s2plain(t, `{{ "one two three" | truncatewords: 4 }}`))
+}
+
+func TestS2_TruncateWords_ExactWordCount(t *testing.T) {
+ assert.Equal(t, "one two three", s2plain(t, `{{ "one two three" | truncatewords: 3 }}`))
+}
+
+func TestS2_TruncateWords_NIsZero(t *testing.T) {
+ // n=0 → behaves like n=1 (keeps first word) — regression guard
+ assert.Equal(t, "Ground...", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncatewords: 0 }}`))
+}
+
+func TestS2_TruncateWords_N1(t *testing.T) {
+ assert.Equal(t, "Ground...", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncatewords: 1 }}`))
+}
+
+func TestS2_TruncateWords_BasicThree(t *testing.T) {
+ assert.Equal(t, "Ground control to...", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncatewords: 3 }}`))
+}
+
+func TestS2_TruncateWords_CustomEllipsis(t *testing.T) {
+ assert.Equal(t, "Ground control to--", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncatewords: 3, "--" }}`))
+}
+
+func TestS2_TruncateWords_EmptyEllipsis(t *testing.T) {
+ assert.Equal(t, "Ground control to", s2plain(t,
+ `{{ "Ground control to Major Tom." | truncatewords: 3, "" }}`))
+}
+
+func TestS2_TruncateWords_WhitespaceNormalized(t *testing.T) {
+ // tabs and newlines in source: words are joined with single spaces — regression guard
+ s2eq(t, "one two three...", `{{ s | truncatewords: 3 }}`,
+ map[string]any{"s": "one two\tthree\nfour five"})
+}
+
+// ── A13. size ────────────────────────────────────────────────────────────────
+
+func TestS2_Size_String(t *testing.T) {
+ assert.Equal(t, "6", s2plain(t, `{{ "foobar" | size }}`))
+}
+
+func TestS2_Size_Array(t *testing.T) {
+ s2eq(t, "3", `{{ arr | size }}`, map[string]any{"arr": []any{1, 2, 3}})
+}
+
+func TestS2_Size_EmptyString(t *testing.T) {
+ assert.Equal(t, "0", s2plain(t, `{{ "" | size }}`))
+}
+
+func TestS2_Size_EmptyArray(t *testing.T) {
+ s2eq(t, "0", `{{ arr | size }}`, map[string]any{"arr": []any{}})
+}
+
+func TestS2_Size_Unicode(t *testing.T) {
+ // Size counts characters (runes), not bytes
+ assert.Equal(t, "3", s2plain(t, `{{ "日本語" | size }}`))
+}
+
+func TestS2_Size_InCondition(t *testing.T) {
+ // Filter chains are not valid directly in {% if %} — must assign first
+ s2eq(t, "long", `{% assign n = s | size %}{% if n > 5 %}long{% else %}short{% endif %}`,
+ map[string]any{"s": "foobar"})
+}
+
+// ── A14. slice ───────────────────────────────────────────────────────────────
+
+func TestS2_Slice_String_Basic(t *testing.T) {
+ assert.Equal(t, "oob", s2plain(t, `{{ "foobar" | slice: 1, 3 }}`))
+}
+
+func TestS2_Slice_String_SingleChar_Default(t *testing.T) {
+ assert.Equal(t, "o", s2plain(t, `{{ "foobar" | slice: 1 }}`))
+}
+
+func TestS2_Slice_String_NegativeStart(t *testing.T) {
+ assert.Equal(t, "ar", s2plain(t, `{{ "foobar" | slice: -2, 2 }}`))
+}
+
+func TestS2_Slice_String_StartBeyondEnd(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "foobar" | slice: 100 }}`))
+}
+
+func TestS2_Slice_String_LengthBeyondEnd(t *testing.T) {
+ assert.Equal(t, "oobar", s2plain(t, `{{ "foobar" | slice: 1, 1000 }}`))
+}
+
+func TestS2_Slice_String_NegativeLength_NoOutput(t *testing.T) {
+ // slice with negative length is clamped to zero — regression guard (no panic)
+ assert.Equal(t, "", s2plain(t, `{{ "foobar" | slice: 0, -1 }}`))
+}
+
+func TestS2_Slice_String_NegativeStartClampedToZero(t *testing.T) {
+ // start -100 on 6-char string → clamped to 0; length=1 → "f"
+ assert.Equal(t, "f", s2plain(t, `{{ "foobar" | slice: -100 }}`))
+}
+
+func TestS2_Slice_Array_Basic(t *testing.T) {
+ s2eq(t, "b c", `{{ arr | slice: 1, 2 | join: " " }}`,
+ map[string]any{"arr": []any{"a", "b", "c", "d"}})
+}
+
+func TestS2_Slice_Array_NegativeStart(t *testing.T) {
+ s2eq(t, "d", `{{ arr | slice: -1 | join: "" }}`,
+ map[string]any{"arr": []any{"a", "b", "c", "d"}})
+}
+
+func TestS2_Slice_Unicode_Runes(t *testing.T) {
+ // Slice works on Unicode code points, not bytes
+ assert.Equal(t, "本語", s2plain(t, `{{ "日本語" | slice: 1, 2 }}`))
+}
+
+// ── A15. squish ───────────────────────────────────────────────────────────────
+
+func TestS2_Squish_CollapseSpaces(t *testing.T) {
+ assert.Equal(t, "Hello World", s2plain(t, `{{ " Hello World " | squish }}`))
+}
+
+func TestS2_Squish_CollapseTabsAndNewlines(t *testing.T) {
+ s2eq(t, "foo bar boo", `{{ s | squish }}`,
+ map[string]any{"s": " foo bar\n\t boo "})
+}
+
+func TestS2_Squish_WhitespaceOnly(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ " " | squish }}`))
+}
+
+func TestS2_Squish_EmptyString(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "" | squish }}`))
+}
+
+func TestS2_Squish_AlreadyClean(t *testing.T) {
+ assert.Equal(t, "clean string", s2plain(t, `{{ "clean string" | squish }}`))
+}
+
+// ── A16. h (alias for escape) ─────────────────────────────────────────────────
+
+func TestS2_H_EscapesHtml(t *testing.T) {
+ assert.Equal(t, "<strong>", s2plain(t, `{{ "" | h }}`))
+}
+
+func TestS2_H_AllSpecialChars(t *testing.T) {
+ // Go's html.EscapeString encodes " as " (not ")
+ s2eq(t, "<p class="x">&hello</p>", `{{ s | h }}`,
+ map[string]any{"s": `&hello
`})
+}
+
+func TestS2_H_Number(t *testing.T) {
+ assert.Equal(t, "42", s2plain(t, `{{ 42 | h }}`))
+}
+
+// ── A17. xml_escape ───────────────────────────────────────────────────────────
+
+func TestS2_XmlEscape_Basic(t *testing.T) {
+ // Go's html.EscapeString encodes " as "
+ s2eq(t, "<tag>&"hello"", `{{ s | xml_escape }}`,
+ map[string]any{"s": `&"hello"`})
+}
+
+func TestS2_XmlEscape_Apos(t *testing.T) {
+ s2eq(t, "it's", `{{ s | xml_escape }}`,
+ map[string]any{"s": "it's"})
+}
+
+func TestS2_XmlEscape_EmptyString(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "" | xml_escape }}`))
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// B. HTML Filters
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS2_Escape_AllSpecialChars(t *testing.T) {
+ // Go's html.EscapeString encodes " as " (not ")
+ s2eq(t, "<p>&"test"</p>", `{{ s | escape }}`,
+ map[string]any{"s": `&"test"
`})
+}
+
+func TestS2_Escape_Idempotent(t *testing.T) {
+ // escape of an already-escaped string — does NOT double-escape
+ // This is the raw escape filter (not escape_once)
+ s2eq(t, "<p>", `{{ s | escape }}`,
+ map[string]any{"s": "<p>"})
+}
+
+func TestS2_Escape_CleanString(t *testing.T) {
+ assert.Equal(t, "hello world", s2plain(t, `{{ "hello world" | escape }}`))
+}
+
+func TestS2_EscapeOnce_DoesNotDoubleEscape(t *testing.T) {
+ // escape_once leaves already-escaped sequences alone
+ s2eq(t, "<p>&already</p>", `{{ s | escape_once }}`,
+ map[string]any{"s": "<p>&already"})
+}
+
+func TestS2_EscapeOnce_EscapesUnescaped(t *testing.T) {
+ s2eq(t, "<b>bold</b>", `{{ s | escape_once }}`,
+ map[string]any{"s": "bold "})
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// C. URL / Encoding Filters
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS2_UrlEncode_SpacesAndSpecialChars(t *testing.T) {
+ assert.Equal(t, "foo+bar+baz", s2plain(t, `{{ "foo bar baz" | url_encode }}`))
+}
+
+func TestS2_UrlEncode_SpecialSymbols(t *testing.T) {
+ assert.Equal(t, "foo%40bar.com", s2plain(t, `{{ "foo@bar.com" | url_encode }}`))
+}
+
+func TestS2_UrlDecode_Basic(t *testing.T) {
+ assert.Equal(t, "foo bar baz", s2plain(t, `{{ "foo+bar+baz" | url_decode }}`))
+}
+
+func TestS2_UrlEncodeDecode_RoundTrip(t *testing.T) {
+ s2eq(t, "foo@bar.com", `{{ s | url_encode | url_decode }}`,
+ map[string]any{"s": "foo@bar.com"})
+}
+
+func TestS2_Base64Encode_Basic(t *testing.T) {
+ assert.Equal(t, "aGVsbG8=", s2plain(t, `{{ "hello" | base64_encode }}`))
+}
+
+func TestS2_Base64Decode_Basic(t *testing.T) {
+ assert.Equal(t, "hello", s2plain(t, `{{ "aGVsbG8=" | base64_decode }}`))
+}
+
+func TestS2_Base64EncodeDecode_RoundTrip(t *testing.T) {
+ s2eq(t, "hello world", `{{ s | base64_encode | base64_decode }}`,
+ map[string]any{"s": "hello world"})
+}
+
+func TestS2_Base64UrlSafeEncode_NoPlusOrSlash(t *testing.T) {
+ // URL-safe base64 uses - and _ instead of + and /
+ out := s2plain(t, `{{ "hello world+/" | base64_url_safe_encode }}`)
+ assert.NotContains(t, out, "+")
+ assert.NotContains(t, out, "/")
+}
+
+func TestS2_Base64UrlSafe_RoundTrip(t *testing.T) {
+ s2eq(t, "hello world+/!", `{{ s | base64_url_safe_encode | base64_url_safe_decode }}`,
+ map[string]any{"s": "hello world+/!"})
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// D. Math Filters
+// ═════════════════════════════════════════════════════════════════════════════
+
+// ── D1. abs ──────────────────────────────────────────────────────────────────
+
+func TestS2_Abs_Positive(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 5 | abs }}`))
+}
+
+func TestS2_Abs_Negative(t *testing.T) {
+ assert.Equal(t, "17", s2plain(t, `{{ -17 | abs }}`))
+}
+
+func TestS2_Abs_NegativeFloat(t *testing.T) {
+ // abs returns float64 but printed as "4" (trailing .0 stripped)
+ assert.Equal(t, "4", s2plain(t, `{{ -4.0 | abs }}`))
+}
+
+func TestS2_Abs_StringNumber(t *testing.T) {
+ assert.Equal(t, "19.86", s2plain(t, `{{ "-19.86" | abs }}`))
+}
+
+func TestS2_Abs_Zero(t *testing.T) {
+ assert.Equal(t, "0", s2plain(t, `{{ 0 | abs }}`))
+}
+
+// ── D2. plus / minus / times ─────────────────────────────────────────────────
+
+func TestS2_Plus_Integers(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 2 | plus: 3 }}`))
+}
+
+func TestS2_Plus_Floats(t *testing.T) {
+ assert.Equal(t, "5.5", s2plain(t, `{{ 3.5 | plus: 2.0 }}`))
+}
+
+func TestS2_Plus_IntFloat(t *testing.T) {
+ assert.Equal(t, "5.5", s2plain(t, `{{ 3 | plus: 2.5 }}`))
+}
+
+func TestS2_Plus_StringCoercion(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ "2" | plus: 3 }}`))
+}
+
+func TestS2_Minus_Basic(t *testing.T) {
+ assert.Equal(t, "2", s2plain(t, `{{ 5 | minus: 3 }}`))
+}
+
+func TestS2_Minus_Floats(t *testing.T) {
+ assert.Equal(t, "1.5", s2plain(t, `{{ 4.5 | minus: 3.0 }}`))
+}
+
+func TestS2_Times_BasicInt(t *testing.T) {
+ assert.Equal(t, "12", s2plain(t, `{{ 3 | times: 4 }}`))
+}
+
+func TestS2_Times_Float(t *testing.T) {
+ assert.Equal(t, "7.5", s2plain(t, `{{ 3 | times: 2.5 }}`))
+}
+
+func TestS2_Times_StringCoercion(t *testing.T) {
+ assert.Equal(t, "6", s2plain(t, `{{ "3" | times: 2 }}`))
+}
+
+// ── D3. divided_by ───────────────────────────────────────────────────────────
+
+func TestS2_DividedBy_IntInt_FloorDivision(t *testing.T) {
+ // int / int → integer floor division
+ assert.Equal(t, "3", s2plain(t, `{{ 10 | divided_by: 3 }}`))
+}
+
+func TestS2_DividedBy_IntFloat_FloatResult(t *testing.T) {
+ // int / float → float
+ assert.Equal(t, "3.3333333333333335", s2plain(t, `{{ 10 | divided_by: 3.0 }}`))
+}
+
+func TestS2_DividedBy_FloatInt_FloatResult(t *testing.T) {
+ // float input / int → float result — regression guard
+ s2eq(t, "0.5", `{{ n | divided_by: 4 }}`, map[string]any{"n": float64(2.0)})
+}
+
+func TestS2_DividedBy_FloatFloat(t *testing.T) {
+ assert.Equal(t, "2.5", s2plain(t, `{{ 5.0 | divided_by: 2.0 }}`))
+}
+
+func TestS2_DividedBy_NegativeFloor(t *testing.T) {
+ // Go integer division truncates toward zero: -10 / 3 = -3 (not -4)
+ assert.Equal(t, "-3", s2plain(t, `{{ -10 | divided_by: 3 }}`))
+}
+
+func TestS2_DividedBy_ZeroReturnsError(t *testing.T) {
+ _, err := s2renderErr(t, `{{ 5 | divided_by: 0 }}`, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "divided by 0")
+}
+
+func TestS2_DividedBy_FloatZeroReturnsError(t *testing.T) {
+ _, err := s2renderErr(t, `{{ 5 | divided_by: 0.0 }}`, nil)
+ require.Error(t, err)
+}
+
+// ── D4. modulo ───────────────────────────────────────────────────────────────
+
+func TestS2_Modulo_Basic(t *testing.T) {
+ assert.Equal(t, "1", s2plain(t, `{{ 10 | modulo: 3 }}`))
+}
+
+func TestS2_Modulo_Float(t *testing.T) {
+ assert.Equal(t, "1.5", s2plain(t, `{{ 7.5 | modulo: 3.0 }}`))
+}
+
+func TestS2_Modulo_ZeroReturnsError(t *testing.T) {
+ _, err := s2renderErr(t, `{{ 1 | modulo: 0 }}`, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "divided by 0")
+}
+
+func TestS2_Modulo_NegativeFloor(t *testing.T) {
+ // Ruby uses floor modulo: result has same sign as divisor.
+ // truncated would give -1; floor gives 2 (-10 = (-4)*3 + 2)
+ assert.Equal(t, "2", s2plain(t, `{{ -10 | modulo: 3 }}`))
+ // truncated would give 1; floor gives -2 (10 = (-4)*(-3) + (-2))
+ assert.Equal(t, "-2", s2plain(t, `{{ 10 | modulo: -3 }}`))
+ // float: truncated would give -1.5; floor gives 1.5
+ assert.Equal(t, "1.5", s2plain(t, `{{ -7.5 | modulo: 3.0 }}`))
+}
+
+// ── D5. ceil / floor / round ──────────────────────────────────────────────────
+
+func TestS2_Ceil_Float(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 4.3 | ceil }}`))
+}
+
+func TestS2_Ceil_Negative(t *testing.T) {
+ assert.Equal(t, "-4", s2plain(t, `{{ -4.6 | ceil }}`))
+}
+
+func TestS2_Ceil_StringNumber(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ "4.3" | ceil }}`))
+}
+
+func TestS2_Ceil_AlreadyInteger(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 5.0 | ceil }}`))
+}
+
+func TestS2_Floor_Float(t *testing.T) {
+ assert.Equal(t, "4", s2plain(t, `{{ 4.9 | floor }}`))
+}
+
+func TestS2_Floor_Negative(t *testing.T) {
+ assert.Equal(t, "-5", s2plain(t, `{{ -4.1 | floor }}`))
+}
+
+func TestS2_Floor_StringNumber(t *testing.T) {
+ assert.Equal(t, "3", s2plain(t, `{{ "3.7" | floor }}`))
+}
+
+func TestS2_Round_HalfUp(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 4.6 | round }}`))
+}
+
+func TestS2_Round_HalfDown(t *testing.T) {
+ assert.Equal(t, "4", s2plain(t, `{{ 4.4 | round }}`))
+}
+
+func TestS2_Round_WithPrecision(t *testing.T) {
+ assert.Equal(t, "3.14", s2plain(t, `{{ 3.14159 | round: 2 }}`))
+}
+
+func TestS2_Round_Negative(t *testing.T) {
+ assert.Equal(t, "-5", s2plain(t, `{{ -4.6 | round }}`))
+}
+
+// ── D6. at_least / at_most ───────────────────────────────────────────────────
+
+func TestS2_AtLeast_BelowFloor(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 3 | at_least: 5 }}`))
+}
+
+func TestS2_AtLeast_AboveFloor(t *testing.T) {
+ assert.Equal(t, "8", s2plain(t, `{{ 8 | at_least: 5 }}`))
+}
+
+func TestS2_AtLeast_Equal(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 5 | at_least: 5 }}`))
+}
+
+func TestS2_AtMost_AboveCeil(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 8 | at_most: 5 }}`))
+}
+
+func TestS2_AtMost_BelowCeil(t *testing.T) {
+ assert.Equal(t, "3", s2plain(t, `{{ 3 | at_most: 5 }}`))
+}
+
+func TestS2_AtMost_Equal(t *testing.T) {
+ assert.Equal(t, "5", s2plain(t, `{{ 5 | at_most: 5 }}`))
+}
+
+func TestS2_AtLeast_Float(t *testing.T) {
+ assert.Equal(t, "3.5", s2plain(t, `{{ 2.5 | at_least: 3.5 }}`))
+}
+
+// ── D7. Math in real templates ────────────────────────────────────────────────
+
+func TestS2_Math_InConditional(t *testing.T) {
+ // Use math filter result in condition
+ s2eq(t, "big", `{% assign v = n | times: 2 %}{% if v > 10 %}big{% else %}small{% endif %}`,
+ map[string]any{"n": 6})
+}
+
+func TestS2_Math_PriceCalculation(t *testing.T) {
+ // Realistic e-commerce calculation: trailing .0 is stripped
+ s2eq(t, "90", `{{ price | times: qty | times: discount }}`,
+ map[string]any{"price": 10.0, "qty": 10, "discount": 0.9})
+}
+
+func TestS2_Math_InForLoop(t *testing.T) {
+ // Sum a computed value over loop iterations
+ out := s2plain(t, `{% assign total = 0 %}{% for i in (1..5) %}{% assign total = total | plus: i %}{% endfor %}{{ total }}`)
+ assert.Equal(t, "15", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// E. Date Filters
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS2_Date_FromString(t *testing.T) {
+ t.Setenv("TZ", "UTC")
+ assert.Equal(t, "May", s2plain(t, `{{ "2006-05-05 10:00:00" | date: "%B" }}`))
+}
+
+func TestS2_Date_FromUnixTimestamp(t *testing.T) {
+ t.Setenv("TZ", "UTC")
+ s2eq(t, "07/05/2006", `{{ ts | date: "%m/%d/%Y" }}`,
+ map[string]any{"ts": int64(1152098955)})
+}
+
+func TestS2_Date_FromTimeTime(t *testing.T) {
+ t.Setenv("TZ", "UTC")
+ tm, _ := time.Parse(time.RFC3339, "2015-07-17T15:04:05Z")
+ s2eq(t, "2015", `{{ ts | date: "%Y" }}`, map[string]any{"ts": tm})
+}
+
+func TestS2_Date_NilInputReturnsNil(t *testing.T) {
+ // nil | date: fmt → nil → renders as "" — regression guard
+ s2eq(t, "", `{{ v | date: "%B" }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Date_FormatMonthAndDay(t *testing.T) {
+ t.Setenv("TZ", "UTC")
+ assert.Equal(t, "07/16/2004", s2plain(t,
+ `{{ "Fri Jul 16 01:00:00 2004" | date: "%m/%d/%Y" }}`))
+}
+
+func TestS2_Date_FormatYear(t *testing.T) {
+ t.Setenv("TZ", "UTC")
+ assert.Equal(t, "2006", s2plain(t, `{{ "2006-05-05 10:00:00" | date: "%Y" }}`))
+}
+
+func TestS2_DateToString_US(t *testing.T) {
+ assert.Equal(t, "07 Nov 2008", s2plain(t,
+ `{{ "2008-11-07T13:07:54-08:00" | date_to_string }}`))
+}
+
+func TestS2_DateToString_OrdinalUS(t *testing.T) {
+ assert.Equal(t, "Nov 7th, 2008", s2plain(t,
+ `{{ "2008-11-07T13:07:54-08:00" | date_to_string: "ordinal", "US" }}`))
+}
+
+func TestS2_DateToLongString_Basic(t *testing.T) {
+ assert.Equal(t, "07 November 2008", s2plain(t,
+ `{{ "2008-11-07T13:07:54-08:00" | date_to_long_string }}`))
+}
+
+func TestS2_DateToXmlSchema_Basic(t *testing.T) {
+ out := s2plain(t, `{{ "2008-11-07T13:07:54-08:00" | date_to_xmlschema }}`)
+ assert.Contains(t, out, "2008-11-07")
+}
+
+func TestS2_DateToRfc822_Basic(t *testing.T) {
+ out := s2plain(t, `{{ "2008-11-07T13:07:54-08:00" | date_to_rfc822 }}`)
+ assert.Contains(t, out, "Nov")
+ assert.Contains(t, out, "2008")
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// F. Array Filters
+// ═════════════════════════════════════════════════════════════════════════════
+
+// ── F1. join ─────────────────────────────────────────────────────────────────
+
+func TestS2_Join_Basic(t *testing.T) {
+ s2eq(t, "one two three", `{{ arr | join: " " }}`,
+ map[string]any{"arr": []any{"one", "two", "three"}})
+}
+
+func TestS2_Join_CommaSep(t *testing.T) {
+ s2eq(t, "a,b,c", `{{ arr | join: "," }}`,
+ map[string]any{"arr": []any{"a", "b", "c"}})
+}
+
+func TestS2_Join_EmptyArray(t *testing.T) {
+ s2eq(t, "", `{{ arr | join: " " }}`, map[string]any{"arr": []any{}})
+}
+
+func TestS2_Join_SingleElement(t *testing.T) {
+ s2eq(t, "solo", `{{ arr | join: "," }}`, map[string]any{"arr": []any{"solo"}})
+}
+
+// ── F2. first / last ─────────────────────────────────────────────────────────
+
+func TestS2_First_OnArray(t *testing.T) {
+ s2eq(t, "a", `{{ arr | first }}`, map[string]any{"arr": []any{"a", "b", "c"}})
+}
+
+func TestS2_Last_OnArray(t *testing.T) {
+ s2eq(t, "c", `{{ arr | last }}`, map[string]any{"arr": []any{"a", "b", "c"}})
+}
+
+func TestS2_First_OnString(t *testing.T) {
+ // first on a string returns the first character — regression guard
+ assert.Equal(t, "f", s2plain(t, `{{ "foobar" | first }}`))
+}
+
+func TestS2_Last_OnString(t *testing.T) {
+ // last on a string returns the last character — regression guard
+ assert.Equal(t, "r", s2plain(t, `{{ "foobar" | last }}`))
+}
+
+func TestS2_First_Unicode(t *testing.T) {
+ assert.Equal(t, "日", s2plain(t, `{{ "日本語" | first }}`))
+}
+
+func TestS2_Last_Unicode(t *testing.T) {
+ assert.Equal(t, "語", s2plain(t, `{{ "日本語" | last }}`))
+}
+
+func TestS2_First_AfterSplit(t *testing.T) {
+ assert.Equal(t, "one", s2plain(t, `{{ "one two three" | split: " " | first }}`))
+}
+
+func TestS2_Last_AfterSplit(t *testing.T) {
+ assert.Equal(t, "three", s2plain(t, `{{ "one two three" | split: " " | last }}`))
+}
+
+// ── F3. reverse ───────────────────────────────────────────────────────────────
+
+func TestS2_Reverse_Basic(t *testing.T) {
+ s2eq(t, "c b a", `{{ arr | reverse | join: " " }}`,
+ map[string]any{"arr": []any{"a", "b", "c"}})
+}
+
+func TestS2_Reverse_SingleElement(t *testing.T) {
+ s2eq(t, "x", `{{ arr | reverse | join: "" }}`, map[string]any{"arr": []any{"x"}})
+}
+
+func TestS2_Reverse_OddLength(t *testing.T) {
+ s2eq(t, "5 4 3 2 1", `{{ arr | reverse | join: " " }}`,
+ map[string]any{"arr": []any{1, 2, 3, 4, 5}})
+}
+
+// ── F4. sort / sort_natural ───────────────────────────────────────────────────
+
+func TestS2_Sort_Strings(t *testing.T) {
+ s2eq(t, "apple banana cherry", `{{ arr | sort | join: " " }}`,
+ map[string]any{"arr": []any{"cherry", "apple", "banana"}})
+}
+
+func TestS2_Sort_Numbers(t *testing.T) {
+ s2eq(t, "1 2 3 5", `{{ arr | sort | join: " " }}`,
+ map[string]any{"arr": []any{3, 1, 5, 2}})
+}
+
+func TestS2_Sort_NilLast(t *testing.T) {
+ // nil values go to the end — regression guard
+ input := []any{
+ map[string]any{"price": 4, "handle": "alpha"},
+ map[string]any{"handle": "beta"},
+ map[string]any{"price": 1, "handle": "gamma"},
+ map[string]any{"handle": "delta"},
+ map[string]any{"price": 2, "handle": "epsilon"},
+ }
+ s2eq(t, "gamma epsilon alpha beta delta",
+ `{{ arr | sort: "price" | map: "handle" | join: " " }}`,
+ map[string]any{"arr": input})
+}
+
+func TestS2_Sort_ByProperty(t *testing.T) {
+ input := []any{
+ map[string]any{"name": "Zebra"},
+ map[string]any{"name": "Apple"},
+ map[string]any{"name": "Mango"},
+ }
+ s2eq(t, "Apple Mango Zebra",
+ `{{ arr | sort: "name" | map: "name" | join: " " }}`,
+ map[string]any{"arr": input})
+}
+
+func TestS2_SortNatural_CaseInsensitive(t *testing.T) {
+ s2eq(t, "Apple banana Cherry",
+ `{{ arr | sort_natural | join: " " }}`,
+ map[string]any{"arr": []any{"Cherry", "Apple", "banana"}})
+}
+
+func TestS2_SortNatural_NilLast(t *testing.T) {
+ // nil property values must go last — regression guard (no panic)
+ input := []any{
+ map[string]any{"price": "4", "handle": "alpha"},
+ map[string]any{"handle": "beta"},
+ map[string]any{"price": "1", "handle": "gamma"},
+ map[string]any{"handle": "delta"},
+ map[string]any{"price": "2", "handle": "epsilon"},
+ }
+ s2eq(t, "gamma epsilon alpha beta delta",
+ `{{ arr | sort_natural: "price" | map: "handle" | join: " " }}`,
+ map[string]any{"arr": input})
+}
+
+func TestS2_SortNatural_NilElementsNoParanic(t *testing.T) {
+ // Arrays with nil entries must not panic — regression guard
+ s2eq(t, "apple cherry",
+ `{{ arr | sort_natural | compact | join: " " }}`,
+ map[string]any{"arr": []any{nil, "cherry", nil, "apple"}})
+}
+
+// ── F5. map ───────────────────────────────────────────────────────────────────
+
+func TestS2_Map_BasicProperty(t *testing.T) {
+ input := []any{
+ map[string]any{"name": "Alice"},
+ map[string]any{"name": "Bob"},
+ }
+ s2eq(t, "Alice Bob", `{{ arr | map: "name" | join: " " }}`,
+ map[string]any{"arr": input})
+}
+
+func TestS2_Map_ThenFilter(t *testing.T) {
+ input := []any{
+ map[string]any{"title": "One", "published": true},
+ map[string]any{"title": "Two", "published": false},
+ map[string]any{"title": "Three", "published": true},
+ }
+ s2eq(t, "One Three",
+ `{{ posts | where: "published", true | map: "title" | join: " " }}`,
+ map[string]any{"posts": input})
+}
+
+// ── F6. sum ───────────────────────────────────────────────────────────────────
+
+func TestS2_Sum_Integers(t *testing.T) {
+ s2eq(t, "10", `{{ arr | sum }}`, map[string]any{"arr": []any{1, 2, 3, 4}})
+}
+
+func TestS2_Sum_ByProperty(t *testing.T) {
+ input := []any{
+ map[string]any{"qty": 3},
+ map[string]any{"qty": 7},
+ }
+ s2eq(t, "10", `{{ arr | sum: "qty" }}`, map[string]any{"arr": input})
+}
+
+func TestS2_Sum_MixedStringNumbers(t *testing.T) {
+ s2eq(t, "10", `{{ arr | sum }}`, map[string]any{"arr": []any{1, 2, "3", "4"}})
+}
+
+func TestS2_Sum_EmptyArray(t *testing.T) {
+ s2eq(t, "0", `{{ arr | sum }}`, map[string]any{"arr": []any{}})
+}
+
+// ── F7. compact / uniq ────────────────────────────────────────────────────────
+
+func TestS2_Compact_RemovesNils(t *testing.T) {
+ s2eq(t, "1 2 3", `{{ arr | compact | join: " " }}`,
+ map[string]any{"arr": []any{1, nil, 2, nil, 3}})
+}
+
+func TestS2_Compact_EmptyArray(t *testing.T) {
+ s2eq(t, "", `{{ arr | compact | join: " " }}`, map[string]any{"arr": []any{}})
+}
+
+func TestS2_Compact_NoNils(t *testing.T) {
+ s2eq(t, "a b c", `{{ arr | compact | join: " " }}`,
+ map[string]any{"arr": []any{"a", "b", "c"}})
+}
+
+func TestS2_Uniq_Basic(t *testing.T) {
+ s2eq(t, "1 2 3", `{{ arr | uniq | join: " " }}`,
+ map[string]any{"arr": []any{1, 1, 2, 3, 2, 1}})
+}
+
+func TestS2_Uniq_PreservesOrder(t *testing.T) {
+ s2eq(t, "c a b", `{{ arr | uniq | join: " " }}`,
+ map[string]any{"arr": []any{"c", "a", "c", "b", "a"}})
+}
+
+func TestS2_Uniq_EmptyArray(t *testing.T) {
+ s2eq(t, "0", `{{ arr | uniq | size }}`, map[string]any{"arr": []any{}})
+}
+
+// ── F8. concat ────────────────────────────────────────────────────────────────
+
+func TestS2_Concat_Basic(t *testing.T) {
+ s2eq(t, "1 2 3 4", `{{ a | concat: b | join: " " }}`,
+ map[string]any{"a": []any{1, 2}, "b": []any{3, 4}})
+}
+
+func TestS2_Concat_OriginalUnchanged(t *testing.T) {
+ // concat is pure — original array not mutated
+ s2eq(t, "2", `{{ a | size }}`,
+ map[string]any{"a": []any{1, 2}, "b": []any{3, 4}})
+}
+
+func TestS2_Concat_EmptyLeft(t *testing.T) {
+ s2eq(t, "3 4", `{{ arr | concat: extra | join: " " }}`,
+ map[string]any{"arr": []any{}, "extra": []any{3, 4}})
+}
+
+// ── F9. push / pop / unshift / shift ─────────────────────────────────────────
+
+func TestS2_Push_ReturnsNewArray(t *testing.T) {
+ s2eq(t, "5", `{{ arr | push: "new" | size }}`,
+ map[string]any{"arr": []any{"a", "b", "c", "d"}})
+}
+
+func TestS2_Push_OriginalUnchanged(t *testing.T) {
+ s2eq(t, "4", `{{ arr | size }}`,
+ map[string]any{"arr": []any{"a", "b", "c", "d"}})
+}
+
+func TestS2_Pop_ReturnsNewArray(t *testing.T) {
+ s2eq(t, "3", `{{ arr | pop | size }}`,
+ map[string]any{"arr": []any{"a", "b", "c", "d"}})
+}
+
+func TestS2_Unshift_PrependElement(t *testing.T) {
+ s2eq(t, "new", `{{ arr | unshift: "new" | first }}`,
+ map[string]any{"arr": []any{"a", "b"}})
+}
+
+func TestS2_Shift_RemovesFirst(t *testing.T) {
+ s2eq(t, "b", `{{ arr | shift | first }}`,
+ map[string]any{"arr": []any{"a", "b", "c"}})
+}
+
+// ── F10. where / reject ────────────────────────────────────────────────────────
+
+func TestS2_Where_ByValue(t *testing.T) {
+ products := []any{
+ map[string]any{"title": "A", "type": "kitchen"},
+ map[string]any{"title": "B", "type": "living"},
+ map[string]any{"title": "C", "type": "kitchen"},
+ }
+ s2eq(t, "A C",
+ `{{ products | where: "type", "kitchen" | map: "title" | join: " " }}`,
+ map[string]any{"products": products})
+}
+
+func TestS2_Where_TruthyProperty(t *testing.T) {
+ items := []any{
+ map[string]any{"name": "A", "available": true},
+ map[string]any{"name": "B"},
+ map[string]any{"name": "C", "available": true},
+ }
+ s2eq(t, "A C",
+ `{{ items | where: "available" | map: "name" | join: " " }}`,
+ map[string]any{"items": items})
+}
+
+func TestS2_Reject_Basic(t *testing.T) {
+ products := []any{
+ map[string]any{"title": "A", "type": "kitchen"},
+ map[string]any{"title": "B", "type": "living"},
+ map[string]any{"title": "C", "type": "kitchen"},
+ }
+ s2eq(t, "B",
+ `{{ products | reject: "type", "kitchen" | map: "title" | join: " " }}`,
+ map[string]any{"products": products})
+}
+
+// ── F11. find / find_index / has ─────────────────────────────────────────────
+
+func TestS2_Find_Basic(t *testing.T) {
+ items := []any{
+ map[string]any{"id": 1, "name": "foo"},
+ map[string]any{"id": 2, "name": "bar"},
+ }
+ // find returns the matching map; access its field via assign + map access
+ s2eq(t, "bar", `{% assign f = items | find: "id", 2 %}{{ f.name }}`,
+ map[string]any{"items": items})
+}
+
+func TestS2_Find_NotFound(t *testing.T) {
+ items := []any{map[string]any{"id": 1}}
+ s2eq(t, "", `{% assign f = items | find: "id", 99 %}{{ f }}`,
+ map[string]any{"items": items})
+}
+
+func TestS2_FindIndex_Basic(t *testing.T) {
+ items := []any{
+ map[string]any{"id": 1},
+ map[string]any{"id": 2},
+ map[string]any{"id": 3},
+ }
+ s2eq(t, "1", `{{ items | find_index: "id", 2 }}`,
+ map[string]any{"items": items})
+}
+
+func TestS2_FindIndex_NotFound(t *testing.T) {
+ // find_index returns nil when not found; nil renders as ""
+ items := []any{map[string]any{"id": 1}}
+ s2eq(t, "", `{{ items | find_index: "id", 99 }}`,
+ map[string]any{"items": items})
+}
+
+func TestS2_Has_True(t *testing.T) {
+ items := []any{
+ map[string]any{"id": 1},
+ map[string]any{"id": 2},
+ }
+ s2eq(t, "true", `{{ items | has: "id", 2 }}`, map[string]any{"items": items})
+}
+
+func TestS2_Has_False(t *testing.T) {
+ items := []any{map[string]any{"id": 1}}
+ s2eq(t, "false", `{{ items | has: "id", 99 }}`, map[string]any{"items": items})
+}
+
+// ── F12. group_by ─────────────────────────────────────────────────────────────
+
+func TestS2_GroupBy_Basic(t *testing.T) {
+ items := []any{
+ map[string]any{"name": "A", "type": "x"},
+ map[string]any{"name": "B", "type": "y"},
+ map[string]any{"name": "C", "type": "x"},
+ }
+ // group_by returns array of {name, items} maps
+ out := s2render(t, `{% assign g = items | group_by: "type" %}{{ g | size }}`,
+ map[string]any{"items": items})
+ assert.Equal(t, "2", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// G. Misc Filters
+// ═════════════════════════════════════════════════════════════════════════════
+
+// ── G1. default ──────────────────────────────────────────────────────────────
+
+func TestS2_Default_Nil(t *testing.T) {
+ s2eq(t, "fallback", `{{ v | default: "fallback" }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Default_False(t *testing.T) {
+ s2eq(t, "fallback", `{{ v | default: "fallback" }}`, map[string]any{"v": false})
+}
+
+func TestS2_Default_EmptyString(t *testing.T) {
+ s2eq(t, "fallback", `{{ v | default: "fallback" }}`, map[string]any{"v": ""})
+}
+
+func TestS2_Default_EmptyArray(t *testing.T) {
+ s2eq(t, "fallback", `{{ v | default: "fallback" }}`, map[string]any{"v": []any{}})
+}
+
+func TestS2_Default_TruthyString(t *testing.T) {
+ s2eq(t, "hello", `{{ v | default: "fallback" }}`, map[string]any{"v": "hello"})
+}
+
+func TestS2_Default_ZeroIsNotDefault(t *testing.T) {
+ // 0 is truthy in Liquid — default must NOT activate
+ s2eq(t, "0", `{{ v | default: "fallback" }}`, map[string]any{"v": 0})
+}
+
+func TestS2_Default_AllowFalse(t *testing.T) {
+ // allow_false: true → false does NOT trigger default
+ s2eq(t, "false", `{{ v | default: "fallback", allow_false: true }}`,
+ map[string]any{"v": false})
+}
+
+func TestS2_Default_AllowFalse_NilStillTriggersDefault(t *testing.T) {
+ // allow_false: true → nil still triggers default
+ s2eq(t, "fallback", `{{ v | default: "fallback", allow_false: true }}`,
+ map[string]any{"v": nil})
+}
+
+func TestS2_Default_Float(t *testing.T) {
+ s2eq(t, "4.99", `{{ v | default: 2.99 }}`, map[string]any{"v": 4.99})
+}
+
+// ── G2. json / jsonify / to_integer ──────────────────────────────────────────
+
+func TestS2_JSON_String(t *testing.T) {
+ assert.Equal(t, `"hello"`, s2plain(t, `{{ "hello" | json }}`))
+}
+
+func TestS2_JSON_Integer(t *testing.T) {
+ assert.Equal(t, "42", s2plain(t, `{{ 42 | json }}`))
+}
+
+func TestS2_JSON_Bool(t *testing.T) {
+ assert.Equal(t, "true", s2plain(t, `{{ true | json }}`))
+}
+
+func TestS2_JSON_Array(t *testing.T) {
+ s2eq(t, `[1,2,3]`, `{{ arr | json }}`, map[string]any{"arr": []any{1, 2, 3}})
+}
+
+func TestS2_ToInteger_FloatString(t *testing.T) {
+ assert.Equal(t, "3", s2plain(t, `{{ "3.9" | to_integer }}`))
+}
+
+func TestS2_ToInteger_IntString(t *testing.T) {
+ assert.Equal(t, "42", s2plain(t, `{{ "42" | to_integer }}`))
+}
+
+func TestS2_ToInteger_TrueIsOne(t *testing.T) {
+ assert.Equal(t, "1", s2plain(t, `{{ true | to_integer }}`))
+}
+
+func TestS2_ToInteger_FalseIsZero(t *testing.T) {
+ assert.Equal(t, "0", s2plain(t, `{{ false | to_integer }}`))
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// H. Filter Chaining
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS2_Chain_SplitReverseJoin(t *testing.T) {
+ assert.Equal(t, "c,b,a", s2plain(t, `{{ "a,b,c" | split: "," | reverse | join: "," }}`))
+}
+
+func TestS2_Chain_DowncaseTruncate(t *testing.T) {
+ assert.Equal(t, "hello...", s2plain(t, `{{ "HELLO WORLD" | downcase | truncate: 8 }}`))
+}
+
+func TestS2_Chain_StripHtmlDowncaseStrip(t *testing.T) {
+ assert.Equal(t, "hello world", s2plain(t,
+ `{{ " Hello World " | strip_html | downcase | strip }}`))
+}
+
+func TestS2_Chain_MathChain(t *testing.T) {
+ // 3 | times: 4 | minus: 2 | divided_by: 2 = (12 - 2) / 2 = 5
+ assert.Equal(t, "5", s2plain(t, `{{ 3 | times: 4 | minus: 2 | divided_by: 2 }}`))
+}
+
+func TestS2_Chain_ArrayChain(t *testing.T) {
+ products := []any{
+ map[string]any{"title": "Tomato", "type": "fruit"},
+ map[string]any{"title": "Banana", "type": "fruit"},
+ map[string]any{"title": "Carrot", "type": "vegetable"},
+ map[string]any{"title": "Apple", "type": "fruit"},
+ }
+ s2eq(t, "Apple Banana Tomato",
+ `{{ products | where: "type", "fruit" | map: "title" | sort | join: " " }}`,
+ map[string]any{"products": products})
+}
+
+func TestS2_Chain_InForLoop(t *testing.T) {
+ s2eq(t, "",
+ `{% for w in s | split: "," %}<{{ w | upcase }}>{% endfor %}`,
+ map[string]any{"s": "a,b,c"})
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// I. Nil Safety
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS2_Nil_DowncaseEmpty(t *testing.T) {
+ s2eq(t, "", `{{ v | downcase }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_AppendEmpty(t *testing.T) {
+ s2eq(t, "!!", `{{ v | append: "!!" }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_SizeZero(t *testing.T) {
+ s2eq(t, "0", `{{ v | size }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_StripEmpty(t *testing.T) {
+ s2eq(t, "", `{{ v | strip }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_ReverseEmpty(t *testing.T) {
+ s2eq(t, "", `{{ v | reverse | join: "" }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_URLEncodeEmpty(t *testing.T) {
+ s2eq(t, "", `{{ v | url_encode }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_DateNil(t *testing.T) {
+ // nil date filter → output empty string — regression guard
+ s2eq(t, "", `{{ v | date: "%B" }}`, map[string]any{"v": nil})
+}
+
+func TestS2_Nil_JoinEmpty(t *testing.T) {
+ s2eq(t, "", `{{ v | join: "," }}`, map[string]any{"v": nil})
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// J. Regression Guards — exact behaviors of bugs fixed in this session
+// ═════════════════════════════════════════════════════════════════════════════
+
+// J1. truncate: n <= len(ellipsis) returns the ellipsis
+func TestS2_Regression_Truncate_ZeroN_ReturnsEllipsis(t *testing.T) {
+ assert.Equal(t, "...", s2plain(t, `{{ "1234567890" | truncate: 0 }}`))
+}
+
+func TestS2_Regression_Truncate_SmallN_ReturnsEllipsis(t *testing.T) {
+ assert.Equal(t, "...", s2plain(t, `{{ "1234567890" | truncate: 2 }}`))
+}
+
+// J2. truncate: exact-fit string is NOT truncated
+func TestS2_Regression_Truncate_ExactFit_NoEllipsis(t *testing.T) {
+ assert.Equal(t, "hello", s2plain(t, `{{ "hello" | truncate: 5 }}`))
+}
+
+// J3. truncatewords: n=0 behaves like n=1
+func TestS2_Regression_TruncateWords_ZeroN_KeepsFirstWord(t *testing.T) {
+ assert.Equal(t, "one...", s2plain(t, `{{ "one two three" | truncatewords: 0 }}`))
+}
+
+// J4. truncatewords: fewer words than n → no ellipsis added
+func TestS2_Regression_TruncateWords_FewerWords_NoEllipsis(t *testing.T) {
+ assert.Equal(t, "one two", s2plain(t, `{{ "one two" | truncatewords: 5 }}`))
+}
+
+// J5. divided_by: float / int = float (not integer floor division)
+func TestS2_Regression_DividedBy_FloatDividend_FloatResult(t *testing.T) {
+ s2eq(t, "0.5", `{{ n | divided_by: 4 }}`, map[string]any{"n": float64(2.0)})
+}
+
+// J6. divided_by: int / int = floor (remains integer division)
+func TestS2_Regression_DividedBy_IntDividend_IntResult(t *testing.T) {
+ assert.Equal(t, "3", s2plain(t, `{{ 10 | divided_by: 3 }}`))
+}
+
+// J7. strip_newlines removes \r\n (Windows line endings)
+func TestS2_Regression_StripNewlines_CRLF(t *testing.T) {
+ s2eq(t, "ab", `{{ s | strip_newlines }}`, map[string]any{"s": "a\r\nb"})
+}
+
+// J8. newline_to_br normalizes \r\n → single
+func TestS2_Regression_NewlineToBr_CRLF_NoDuplicate(t *testing.T) {
+ s2eq(t, "a \nb", `{{ s | newline_to_br }}`, map[string]any{"s": "a\r\nb"})
+}
+
+// J9. first/last on strings return first/last rune
+func TestS2_Regression_First_OnString(t *testing.T) {
+ assert.Equal(t, "h", s2plain(t, `{{ "hello" | first }}`))
+}
+
+func TestS2_Regression_Last_OnString(t *testing.T) {
+ assert.Equal(t, "o", s2plain(t, `{{ "hello" | last }}`))
+}
+
+// J10. sort: nil values go last (not first)
+func TestS2_Regression_Sort_NilLast(t *testing.T) {
+ arr := []any{3, nil, 1, nil, 2}
+ // After sort, nils should be at the end
+ out := s2render(t, `{{ arr | sort | last }}`, map[string]any{"arr": arr})
+ assert.Equal(t, "", out) // nil renders as ""
+}
+
+// J11. sort_natural: nil elements in array must not cause panic
+func TestS2_Regression_SortNatural_NilElements_NoPanic(t *testing.T) {
+ arr := []any{nil, "banana", nil, "apple", "cherry"}
+ // Must not panic; nils go last
+ out := s2render(t, `{{ arr | sort_natural | first }}`, map[string]any{"arr": arr})
+ assert.Equal(t, "apple", out)
+}
+
+// J12. slice: negative length clamps to zero (no panic)
+func TestS2_Regression_Slice_NegativeLength_Empty(t *testing.T) {
+ assert.Equal(t, "", s2plain(t, `{{ "foobar" | slice: 0, -1 }}`))
+}
+
+// J13. date: nil input returns nil (renders as "")
+func TestS2_Regression_Date_NilInput(t *testing.T) {
+ s2eq(t, "", `{{ v | date: "%Y" }}`, map[string]any{"v": nil})
+}
+
+// J14. truncatewords: internal whitespace is normalized to single spaces
+func TestS2_Regression_TruncateWords_InternalWhitespaceNormalized(t *testing.T) {
+ s2eq(t, "one two three...", `{{ s | truncatewords: 3 }}`,
+ map[string]any{"s": "one two\tthree\nfour"})
+}
diff --git a/s4_expressions_e2e_test.go b/s4_expressions_e2e_test.go
new file mode 100644
index 00000000..9cd04472
--- /dev/null
+++ b/s4_expressions_e2e_test.go
@@ -0,0 +1,996 @@
+package liquid_test
+
+// s4_expressions_e2e_test.go — Intensive E2E tests for Section 4: Expressões / Literais
+//
+// Coverage matrix:
+// A. Literal output — all Go scalar types, nil, true, false, int, float, string, range
+// B. Comparison operators — ==, !=, <>, <, >, <=, >= across all type combinations
+// C. empty literal — emptiness semantics for every Go container and scalar
+// D. blank literal — blanking semantics for every Go container and scalar
+// E. Range literal — output, for-loop iteration, contains (boundary/mid/far/variable)
+// F. not operator — basic, compound, and precedence over and/or
+// G. nil/null with ordering — all four ordering operators on both sides
+// H. String escape sequences — all supported escapes in output and comparison
+// I. Logical operators — and/or right-associativity, short-circuit edge cases
+// J. Integration — templates combining multiple section 4 features
+// K. Edge cases — assigns, captures, nested loops, unless, case/when
+//
+// Every test function is self-contained: it creates its own engine, so test
+// sharding or parallel agents cannot share state.
+
+import (
+ "testing"
+
+ "github.com/osteele/liquid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+func s4eng() *liquid.Engine { return liquid.NewEngine() }
+
+func s4render(t *testing.T, tpl string, binds map[string]any) string {
+ t.Helper()
+ out, err := s4eng().ParseAndRenderString(tpl, binds)
+ require.NoError(t, err, "template: %q", tpl)
+ return out
+}
+
+func s4renderErr(t *testing.T, tpl string, binds map[string]any) (string, error) {
+ t.Helper()
+ return s4eng().ParseAndRenderString(tpl, binds)
+}
+
+func s4eq(t *testing.T, want, tpl string, binds map[string]any) {
+ t.Helper()
+ require.Equal(t, want, s4render(t, tpl, binds))
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// A. Literal Output
+// ═════════════════════════════════════════════════════════════════════════════
+
+// A1 — nil / null render as empty string
+func TestS4_Literal_NilRendersEmpty(t *testing.T) {
+ s4eq(t, "", `{{ nil }}`, nil)
+}
+
+func TestS4_Literal_NullRendersEmpty(t *testing.T) {
+ s4eq(t, "", `{{ null }}`, nil)
+}
+
+func TestS4_Literal_GoNilBindingRendersEmpty(t *testing.T) {
+ s4eq(t, "", `{{ v }}`, map[string]any{"v": nil})
+}
+
+func TestS4_Literal_UnsetVarRendersEmpty(t *testing.T) {
+ s4eq(t, "", `{{ missing }}`, nil)
+}
+
+// A2 — boolean literals
+func TestS4_Literal_TrueRendersTrue(t *testing.T) {
+ s4eq(t, "true", `{{ true }}`, nil)
+}
+
+func TestS4_Literal_FalseRendersFalse(t *testing.T) {
+ s4eq(t, "false", `{{ false }}`, nil)
+}
+
+func TestS4_Literal_GoBoolBindingTrue(t *testing.T) {
+ s4eq(t, "true", `{{ v }}`, map[string]any{"v": true})
+}
+
+func TestS4_Literal_GoBoolBindingFalse(t *testing.T) {
+ s4eq(t, "false", `{{ v }}`, map[string]any{"v": false})
+}
+
+// A3 — integer literals (template literals and Go bindings)
+func TestS4_Literal_PositiveInt(t *testing.T) {
+ s4eq(t, "42", `{{ 42 }}`, nil)
+}
+
+func TestS4_Literal_NegativeInt(t *testing.T) {
+ s4eq(t, "-7", `{{ -7 }}`, nil)
+}
+
+func TestS4_Literal_Zero(t *testing.T) {
+ s4eq(t, "0", `{{ 0 }}`, nil)
+}
+
+func TestS4_Literal_GoInt(t *testing.T) {
+ s4eq(t, "100", `{{ v }}`, map[string]any{"v": 100})
+}
+
+func TestS4_Literal_GoInt64(t *testing.T) {
+ s4eq(t, "9876543210", `{{ v }}`, map[string]any{"v": int64(9876543210)})
+}
+
+func TestS4_Literal_GoUint(t *testing.T) {
+ s4eq(t, "255", `{{ v }}`, map[string]any{"v": uint(255)})
+}
+
+// A4 — float literals
+func TestS4_Literal_PositiveFloat(t *testing.T) {
+ s4eq(t, "2.5", `{{ 2.5 }}`, nil)
+}
+
+func TestS4_Literal_NegativeFloat(t *testing.T) {
+ s4eq(t, "-17.42", `{{ -17.42 }}`, nil)
+}
+
+func TestS4_Literal_GoFloat64(t *testing.T) {
+ s4eq(t, "3.14", `{{ v }}`, map[string]any{"v": 3.14})
+}
+
+// A5 — string literals
+func TestS4_Literal_SingleQuotedString(t *testing.T) {
+ s4eq(t, "hello", `{{ 'hello' }}`, nil)
+}
+
+func TestS4_Literal_DoubleQuotedString(t *testing.T) {
+ s4eq(t, "world", `{{ "world" }}`, nil)
+}
+
+func TestS4_Literal_EmptyString(t *testing.T) {
+ s4eq(t, "", `{{ "" }}`, nil)
+}
+
+func TestS4_Literal_StringWithSpaces(t *testing.T) {
+ s4eq(t, "hello world", `{{ "hello world" }}`, nil)
+}
+
+func TestS4_Literal_StringWithEmoji(t *testing.T) {
+ s4eq(t, "🔥", `{{ '🔥' }}`, nil)
+}
+
+func TestS4_Literal_GoStringBinding(t *testing.T) {
+ s4eq(t, "bound", `{{ v }}`, map[string]any{"v": "bound"})
+}
+
+// A6 — range literals
+func TestS4_Literal_RangeOutputFormat(t *testing.T) {
+ // Range renders as "start..end"
+ s4eq(t, "1..5", `{{ (1..5) }}`, nil)
+}
+
+func TestS4_Literal_RangeNegativeBound(t *testing.T) {
+ s4eq(t, "-3..3", `{{ (-3..3) }}`, nil)
+}
+
+func TestS4_Literal_RangeSingleElement(t *testing.T) {
+ s4eq(t, "4..4", `{{ (4..4) }}`, nil)
+}
+
+func TestS4_Literal_RangeWithVariableBound(t *testing.T) {
+ // Range bound from variable
+ s4eq(t, "1..5", `{{ (1..n) }}`, map[string]any{"n": 5})
+}
+
+func TestS4_Literal_RangeForLoopIterates(t *testing.T) {
+ s4eq(t, "1-2-3-4-5", `{% for i in (1..5) %}{{ i }}{% unless forloop.last %}-{% endunless %}{% endfor %}`, nil)
+}
+
+func TestS4_Literal_RangeForLoopNegative(t *testing.T) {
+ s4eq(t, "-2-1012", `{% for i in (-2..2) %}{{ i }}{% endfor %}`, nil)
+}
+
+func TestS4_Literal_RangeForLoopSingleItem(t *testing.T) {
+ s4eq(t, "7", `{% for i in (7..7) %}{{ i }}{% endfor %}`, nil)
+}
+
+func TestS4_Literal_RangeForLoopWithVariableBound(t *testing.T) {
+ s4eq(t, "123", `{% for i in (1..n) %}{{ i }}{% endfor %}`, map[string]any{"n": 3})
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// B. Comparison Operators × type combinations
+// ═════════════════════════════════════════════════════════════════════════════
+
+// B1 — == (equality)
+func TestS4_Eq_IntInt(t *testing.T) {
+ s4eq(t, "yes", `{% if 3 == 3 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_IntFloat(t *testing.T) {
+ // 3 == 3.0 should be true (numeric equality across types)
+ s4eq(t, "yes", `{% if 3 == 3.0 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_StringString(t *testing.T) {
+ s4eq(t, "yes", `{% if "foo" == "foo" %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_NilNil(t *testing.T) {
+ s4eq(t, "yes", `{% if nil == nil %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_NullNull(t *testing.T) {
+ s4eq(t, "yes", `{% if null == null %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_NilNull(t *testing.T) {
+ s4eq(t, "yes", `{% if nil == null %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_BindingNil(t *testing.T) {
+ s4eq(t, "yes", `{% if v == nil %}yes{% else %}no{% endif %}`, map[string]any{"v": nil})
+}
+
+func TestS4_Eq_BindingString(t *testing.T) {
+ s4eq(t, "yes", `{% if v == "hello" %}yes{% else %}no{% endif %}`, map[string]any{"v": "hello"})
+}
+
+func TestS4_Eq_BoolTrue(t *testing.T) {
+ s4eq(t, "yes", `{% if true == true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Eq_BoolFalseTrue(t *testing.T) {
+ s4eq(t, "no", `{% if false == true %}yes{% else %}no{% endif %}`, nil)
+}
+
+// B2 — != (inequality)
+func TestS4_Neq_IntDifferent(t *testing.T) {
+ s4eq(t, "yes", `{% if 1 != 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Neq_IntSame(t *testing.T) {
+ s4eq(t, "no", `{% if 1 != 1 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Neq_StringDifferent(t *testing.T) {
+ s4eq(t, "yes", `{% if "a" != "b" %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Neq_NilNotEqualTrue(t *testing.T) {
+ s4eq(t, "yes", `{% if nil != true %}yes{% else %}no{% endif %}`, nil)
+}
+
+// B3 — <> (alias for !=)
+func TestS4_Diamond_IntDifferent(t *testing.T) {
+ s4eq(t, "yes", `{% if 5 <> 3 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Diamond_IntSame(t *testing.T) {
+ s4eq(t, "no", `{% if 5 <> 5 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Diamond_StringDifferent(t *testing.T) {
+ s4eq(t, "yes", `{% if "x" <> "y" %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Diamond_StringSame(t *testing.T) {
+ s4eq(t, "no", `{% if "x" <> "x" %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Diamond_FloatDifferent(t *testing.T) {
+ s4eq(t, "yes", `{% if 1.5 <> 2.5 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Diamond_CrossTypeEqual(t *testing.T) {
+ // 3 == 3.0 → so 3 <> 3.0 is false
+ s4eq(t, "no", `{% if 3 <> 3.0 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Diamond_BindingBinding(t *testing.T) {
+ s4eq(t, "yes", `{% if a <> b %}yes{% else %}no{% endif %}`,
+ map[string]any{"a": "foo", "b": "bar"})
+}
+
+func TestS4_Diamond_IdenticalToNeq(t *testing.T) {
+ // <> and != must produce exactly the same result
+ out1 := s4render(t, `{% if v <> "x" %}1{% else %}0{% endif %}`, map[string]any{"v": "y"})
+ out2 := s4render(t, `{% if v != "x" %}1{% else %}0{% endif %}`, map[string]any{"v": "y"})
+ require.Equal(t, out1, out2)
+}
+
+// B4 — ordering operators
+func TestS4_Lt_True(t *testing.T) {
+ s4eq(t, "yes", `{% if 1 < 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Lt_False(t *testing.T) {
+ s4eq(t, "no", `{% if 2 < 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Lte_True(t *testing.T) {
+ s4eq(t, "yes", `{% if 2 <= 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Gt_True(t *testing.T) {
+ s4eq(t, "yes", `{% if 3 > 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Gte_True(t *testing.T) {
+ s4eq(t, "yes", `{% if 3 >= 3 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Compare_FloatInt(t *testing.T) {
+ s4eq(t, "yes", `{% if 2.5 > 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Compare_StringOrder(t *testing.T) {
+ s4eq(t, "yes", `{% if "b" > "a" %}yes{% else %}no{% endif %}`, nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// C. empty literal
+// ═════════════════════════════════════════════════════════════════════════════
+
+// C1 — what IS empty
+func TestS4_Empty_EmptyStringIsEmpty(t *testing.T) {
+ s4eq(t, "yes", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": ""})
+}
+
+func TestS4_Empty_EmptyArrayIsEmpty(t *testing.T) {
+ s4eq(t, "yes", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": []any{}})
+}
+
+func TestS4_Empty_EmptyMapIsEmpty(t *testing.T) {
+ s4eq(t, "yes", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": map[string]any{}})
+}
+
+// C2 — what is NOT empty
+func TestS4_Empty_NilIsNotEmpty(t *testing.T) {
+ // nil is NOT empty — empty = collection/string with zero length
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": nil})
+}
+
+func TestS4_Empty_FalseIsNotEmpty(t *testing.T) {
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": false})
+}
+
+func TestS4_Empty_ZeroIsNotEmpty(t *testing.T) {
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": 0})
+}
+
+func TestS4_Empty_WhitespaceStringIsNotEmpty(t *testing.T) {
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": " "})
+}
+
+func TestS4_Empty_NonEmptyStringIsNotEmpty(t *testing.T) {
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": "a"})
+}
+
+func TestS4_Empty_NonEmptyArrayIsNotEmpty(t *testing.T) {
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": []any{1}})
+}
+
+func TestS4_Empty_NonEmptyMapIsNotEmpty(t *testing.T) {
+ s4eq(t, "no", `{% if v == empty %}yes{% else %}no{% endif %}`, map[string]any{"v": map[string]any{"k": 1}})
+}
+
+// C3 — empty never equals itself
+func TestS4_Empty_EmptyDoesNotEqualEmpty(t *testing.T) {
+ // Liquid spec: empty == empty → false (it's a special asymmetric sentinel)
+ s4eq(t, "no", `{% if empty == empty %}yes{% else %}no{% endif %}`, nil)
+}
+
+// C4 — empty renderers as ""
+func TestS4_Empty_RendersAsEmptyString(t *testing.T) {
+ s4eq(t, "", `{{ empty }}`, nil)
+}
+
+// C5 — ordering operators always return false with empty
+func TestS4_Empty_OrderingAlwaysFalse(t *testing.T) {
+ cases := []string{
+ `{% if 1 < empty %}y{% else %}n{% endif %}`,
+ `{% if 1 <= empty %}y{% else %}n{% endif %}`,
+ `{% if 1 > empty %}y{% else %}n{% endif %}`,
+ `{% if 1 >= empty %}y{% else %}n{% endif %}`,
+ `{% if empty < 1 %}y{% else %}n{% endif %}`,
+ `{% if empty <= 1 %}y{% else %}n{% endif %}`,
+ `{% if empty > 1 %}y{% else %}n{% endif %}`,
+ `{% if empty >= 1 %}y{% else %}n{% endif %}`,
+ }
+ for _, c := range cases {
+ assert.Equal(t, "n", s4render(t, c, nil), "template: %s", c)
+ }
+}
+
+// C6 — symmetric: v == empty and empty == v give same result
+func TestS4_Empty_SymmetricComparison(t *testing.T) {
+ binds := map[string]any{"v": ""}
+ out1 := s4render(t, `{% if v == empty %}yes{% else %}no{% endif %}`, binds)
+ out2 := s4render(t, `{% if empty == v %}yes{% else %}no{% endif %}`, binds)
+ require.Equal(t, out1, out2)
+}
+
+// C7 — != empty
+func TestS4_Empty_NotEqualNonEmpty(t *testing.T) {
+ s4eq(t, "yes", `{% if v != empty %}yes{% else %}no{% endif %}`, map[string]any{"v": "hello"})
+}
+
+func TestS4_Empty_NotEqualOnEmptyString(t *testing.T) {
+ s4eq(t, "no", `{% if v != empty %}yes{% else %}no{% endif %}`, map[string]any{"v": ""})
+}
+
+// C8 — assign + empty comparison
+func TestS4_Empty_AssignedEmptyString(t *testing.T) {
+ s4eq(t, "is empty", `{% assign v = "" %}{% if v == empty %}is empty{% else %}not empty{% endif %}`, nil)
+}
+
+func TestS4_Empty_AssignedNonEmpty(t *testing.T) {
+ s4eq(t, "not empty", `{% assign v = "x" %}{% if v == empty %}is empty{% else %}not empty{% endif %}`, nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// D. blank literal
+// ═════════════════════════════════════════════════════════════════════════════
+
+// D1 — what IS blank
+func TestS4_Blank_NilIsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": nil})
+}
+
+func TestS4_Blank_FalseIsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": false})
+}
+
+func TestS4_Blank_EmptyStringIsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": ""})
+}
+
+func TestS4_Blank_WhitespaceStringIsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": " \t\n"})
+}
+
+func TestS4_Blank_EmptyArrayIsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": []any{}})
+}
+
+func TestS4_Blank_EmptyMapIsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": map[string]any{}})
+}
+
+// D2 — what is NOT blank
+func TestS4_Blank_TrueIsNotBlank(t *testing.T) {
+ s4eq(t, "no", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": true})
+}
+
+func TestS4_Blank_ZeroIsNotBlank(t *testing.T) {
+ s4eq(t, "no", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": 0})
+}
+
+func TestS4_Blank_OneIsNotBlank(t *testing.T) {
+ s4eq(t, "no", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": 1})
+}
+
+func TestS4_Blank_NonEmptyStringIsNotBlank(t *testing.T) {
+ s4eq(t, "no", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": "x"})
+}
+
+func TestS4_Blank_NonEmptyArrayIsNotBlank(t *testing.T) {
+ s4eq(t, "no", `{% if v == blank %}yes{% else %}no{% endif %}`, map[string]any{"v": []any{0}})
+}
+
+// D3 — blank equals nil (the nil is blank special case)
+func TestS4_Blank_BlankEqualsNilLiteral(t *testing.T) {
+ s4eq(t, "yes", `{% if blank == nil %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Blank_NilEqualsBlank(t *testing.T) {
+ s4eq(t, "yes", `{% if nil == blank %}yes{% else %}no{% endif %}`, nil)
+}
+
+// D4 — blank renders as ""
+func TestS4_Blank_RendersAsEmptyString(t *testing.T) {
+ s4eq(t, "", `{{ blank }}`, nil)
+}
+
+// D5 — blank vs empty: nil is blank but NOT empty
+func TestS4_Blank_NilIsBlankButNotEmpty(t *testing.T) {
+ s4eq(t, "blank", `{% if v == blank %}blank{% elsif v == empty %}empty{% else %}other{% endif %}`,
+ map[string]any{"v": nil})
+}
+
+// D6 — assign + blank check
+func TestS4_Blank_AssignedWhitespaceIsBlank(t *testing.T) {
+ s4eq(t, "blank", `{% assign v = " " %}{% if v == blank %}blank{% else %}not blank{% endif %}`, nil)
+}
+
+func TestS4_Blank_AssignedNonEmpty(t *testing.T) {
+ s4eq(t, "not blank", `{% assign v = "hi" %}{% if v == blank %}blank{% else %}not blank{% endif %}`, nil)
+}
+
+// D7 — symmetric comparison
+func TestS4_Blank_SymmetricComparison(t *testing.T) {
+ binds := map[string]any{"v": ""}
+ out1 := s4render(t, `{% if v == blank %}yes{% else %}no{% endif %}`, binds)
+ out2 := s4render(t, `{% if blank == v %}yes{% else %}no{% endif %}`, binds)
+ require.Equal(t, out1, out2)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// E. Range literal
+// ═════════════════════════════════════════════════════════════════════════════
+
+// E1 — contains operator: membership inside range
+func TestS4_Range_Contains_Inside(t *testing.T) {
+ s4eq(t, "yes", `{% if (1..10) contains 5 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_AtLowerBound(t *testing.T) {
+ s4eq(t, "yes", `{% if (1..10) contains 1 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_AtUpperBound(t *testing.T) {
+ s4eq(t, "yes", `{% if (1..10) contains 10 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_BelowLower(t *testing.T) {
+ s4eq(t, "no", `{% if (1..10) contains 0 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_AboveUpper(t *testing.T) {
+ s4eq(t, "no", `{% if (1..10) contains 11 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_NegativeRange(t *testing.T) {
+ s4eq(t, "yes", `{% if (-5..5) contains -3 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_NegativeOutside(t *testing.T) {
+ s4eq(t, "no", `{% if (-5..5) contains -6 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_SingleElementRange(t *testing.T) {
+ s4eq(t, "yes", `{% if (7..7) contains 7 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Range_Contains_SingleElementRangeMiss(t *testing.T) {
+ s4eq(t, "no", `{% if (7..7) contains 8 %}yes{% else %}no{% endif %}`, nil)
+}
+
+// E2 — contains with variable bounds and check value
+func TestS4_Range_Contains_VariableBound(t *testing.T) {
+ s4eq(t, "yes", `{% if (1..n) contains 4 %}yes{% else %}no{% endif %}`, map[string]any{"n": 5})
+}
+
+func TestS4_Range_Contains_VariableValue(t *testing.T) {
+ s4eq(t, "yes", `{% if (1..10) contains v %}yes{% else %}no{% endif %}`, map[string]any{"v": 7})
+}
+
+func TestS4_Range_Contains_BothVariable(t *testing.T) {
+ s4eq(t, "yes", `{% if (a..b) contains v %}yes{% else %}no{% endif %}`,
+ map[string]any{"a": 3, "b": 8, "v": 5})
+}
+
+// E3 — range in for loop — correct count and order
+func TestS4_Range_ForLoop_Count(t *testing.T) {
+ s4eq(t, "5", `{% assign c = 0 %}{% for i in (1..5) %}{% assign c = c | plus: 1 %}{% endfor %}{{ c }}`, nil)
+}
+
+func TestS4_Range_ForLoop_Ascending(t *testing.T) {
+ s4eq(t, "12345", `{% for i in (1..5) %}{{ i }}{% endfor %}`, nil)
+}
+
+func TestS4_Range_ForLoop_Reversed(t *testing.T) {
+ s4eq(t, "54321", `{% for i in (1..5) reversed %}{{ i }}{% endfor %}`, nil)
+}
+
+func TestS4_Range_ForLoop_FirstLast(t *testing.T) {
+ s4eq(t, "F.L",
+ `{% for i in (1..3) %}{% if forloop.first %}F{% elsif forloop.last %}L{% else %}.{% endif %}{% endfor %}`,
+ nil)
+}
+
+func TestS4_Range_ForLoop_WithLimit(t *testing.T) {
+ s4eq(t, "123", `{% for i in (1..10) limit:3 %}{{ i }}{% endfor %}`, nil)
+}
+
+func TestS4_Range_ForLoop_WithOffset(t *testing.T) {
+ s4eq(t, "345", `{% for i in (1..5) offset:2 %}{{ i }}{% endfor %}`, nil)
+}
+
+// E4 — range in capture and assign
+func TestS4_Range_CaptureAndCompare(t *testing.T) {
+ // Count via iterating range
+ s4eq(t, "3",
+ `{% assign count = 0 %}{% for i in (1..3) %}{% assign count = count | plus: 1 %}{% endfor %}{{ count }}`,
+ nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// F. not operator
+// ═════════════════════════════════════════════════════════════════════════════
+
+// F1 — basic not
+func TestS4_Not_TrueIsFalse(t *testing.T) {
+ s4eq(t, "no", `{% if not true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_FalseIsTrue(t *testing.T) {
+ s4eq(t, "yes", `{% if not false %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_NilIsTrue(t *testing.T) {
+ s4eq(t, "yes", `{% if not nil %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_ZeroIsFalse(t *testing.T) {
+ // 0 is truthy in Liquid, so not 0 is false
+ s4eq(t, "no", `{% if not 0 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_EmptyStringIsFalse(t *testing.T) {
+ // "" is truthy in Liquid, so not "" is false
+ s4eq(t, "no", `{% if not "" %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_CustomBinding(t *testing.T) {
+ s4eq(t, "yes", `{% if not v %}yes{% else %}no{% endif %}`, map[string]any{"v": false})
+ s4eq(t, "no", `{% if not v %}yes{% else %}no{% endif %}`, map[string]any{"v": true})
+ s4eq(t, "yes", `{% if not v %}yes{% else %}no{% endif %}`, map[string]any{"v": nil})
+}
+
+// F2 — not applied to comparisons
+func TestS4_Not_NotLessThan(t *testing.T) {
+ s4eq(t, "no", `{% if not 1 < 5 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_NotGreaterThan(t *testing.T) {
+ s4eq(t, "yes", `{% if not 5 < 3 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_NotEquals(t *testing.T) {
+ s4eq(t, "yes", `{% if not "a" == "b" %}yes{% else %}no{% endif %}`, nil)
+}
+
+// F3 — not precedence over and/or
+func TestS4_Not_PrecedenceOverOr(t *testing.T) {
+ // not 1 < 2 or not 1 > 2
+ // = (not true) or (not false)
+ // = false or true = true
+ s4eq(t, "yes", `{% if not 1 < 2 or not 1 > 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_PrecedenceOverAnd(t *testing.T) {
+ // not 1 < 2 and not 1 > 2
+ // = (not true) and (not false)
+ // = false and true = false
+ s4eq(t, "no", `{% if not 1 < 2 and not 1 > 2 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_DoubleNot(t *testing.T) {
+ // not not true = not false = true
+ s4eq(t, "yes", `{% if not not true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Not_NotWithContains(t *testing.T) {
+ s4eq(t, "yes", `{% if not arr contains "x" %}yes{% else %}no{% endif %}`,
+ map[string]any{"arr": []any{"a", "b"}})
+}
+
+func TestS4_Not_NotWithContains_False(t *testing.T) {
+ s4eq(t, "no", `{% if not arr contains "a" %}yes{% else %}no{% endif %}`,
+ map[string]any{"arr": []any{"a", "b"}})
+}
+
+// F4 — not in unless (double negation)
+func TestS4_Not_InUnless(t *testing.T) {
+ // unless not x = unless (not truthy) = unless false = renders (truthy)
+ s4eq(t, "yes", `{% unless not v %}yes{% endunless %}`, map[string]any{"v": true})
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// G. nil/null with ordering operators
+// ═════════════════════════════════════════════════════════════════════════════
+
+// G1 — null literal on the left
+func TestS4_NilOrder_NullLtZero(t *testing.T) {
+ s4eq(t, "false", `{% if null < 0 %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_NullLteZero(t *testing.T) {
+ s4eq(t, "false", `{% if null <= 0 %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_NullGtZero(t *testing.T) {
+ s4eq(t, "false", `{% if null > 0 %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_NullGteZero(t *testing.T) {
+ s4eq(t, "false", `{% if null >= 0 %}true{% else %}false{% endif %}`, nil)
+}
+
+// G2 — null literal on the right
+func TestS4_NilOrder_ZeroLtNull(t *testing.T) {
+ s4eq(t, "false", `{% if 0 < null %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_ZeroLteNull(t *testing.T) {
+ s4eq(t, "false", `{% if 0 <= null %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_ZeroGtNull(t *testing.T) {
+ s4eq(t, "false", `{% if 0 > null %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_ZeroGteNull(t *testing.T) {
+ s4eq(t, "false", `{% if 0 >= null %}true{% else %}false{% endif %}`, nil)
+}
+
+// G3 — nil keyword (same as null)
+func TestS4_NilOrder_NilLteZero(t *testing.T) {
+ s4eq(t, "false", `{% if nil <= 0 %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_ZeroLteNil(t *testing.T) {
+ s4eq(t, "false", `{% if 0 <= nil %}true{% else %}false{% endif %}`, nil)
+}
+
+// G4 — Go nil binding in ordering
+func TestS4_NilOrder_GoBindingLt(t *testing.T) {
+ s4eq(t, "false", `{% if v < 1 %}true{% else %}false{% endif %}`, map[string]any{"v": nil})
+}
+
+func TestS4_NilOrder_GoBindingGte(t *testing.T) {
+ s4eq(t, "false", `{% if v >= 0 %}true{% else %}false{% endif %}`, map[string]any{"v": nil})
+}
+
+// G5 — nil IS equal to nil (equality is fine, ordering is not)
+func TestS4_NilOrder_NilEqualsNilIsTrue(t *testing.T) {
+ s4eq(t, "true", `{% if nil == nil %}true{% else %}false{% endif %}`, nil)
+}
+
+func TestS4_NilOrder_NilNotEqualOneIsTrue(t *testing.T) {
+ s4eq(t, "true", `{% if nil != 1 %}true{% else %}false{% endif %}`, nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// H. String escape sequences
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS4_Escape_Newline(t *testing.T) {
+ s4eq(t, "a\nb", `{{ "a\nb" }}`, nil)
+}
+
+func TestS4_Escape_Tab(t *testing.T) {
+ s4eq(t, "a\tb", `{{ "a\tb" }}`, nil)
+}
+
+func TestS4_Escape_CarriageReturn(t *testing.T) {
+ s4eq(t, "a\rb", `{{ "a\rb" }}`, nil)
+}
+
+func TestS4_Escape_SingleQuoteInSingleQuoted(t *testing.T) {
+ s4eq(t, "it's", `{{ 'it\'s' }}`, nil)
+}
+
+func TestS4_Escape_DoubleQuoteInDoubleQuoted(t *testing.T) {
+ s4eq(t, `say "hi"`, `{{ "say \"hi\"" }}`, nil)
+}
+
+func TestS4_Escape_Backslash(t *testing.T) {
+ s4eq(t, `a\b`, `{{ 'a\\b' }}`, nil)
+}
+
+func TestS4_Escape_InSingleQuotedNewline(t *testing.T) {
+ s4eq(t, "x\ny", `{{ 'x\ny' }}`, nil)
+}
+
+// H2 — escape sequences in comparisons
+func TestS4_Escape_CompareWithNewline(t *testing.T) {
+ s4eq(t, "yes", `{% if v == "a\nb" %}yes{% else %}no{% endif %}`,
+ map[string]any{"v": "a\nb"})
+}
+
+func TestS4_Escape_CompareWithBackslash(t *testing.T) {
+ s4eq(t, "yes", `{% if v == "a\\b" %}yes{% else %}no{% endif %}`,
+ map[string]any{"v": `a\b`})
+}
+
+func TestS4_Escape_CompareWithTab(t *testing.T) {
+ s4eq(t, "yes", `{% if v == "x\ty" %}yes{% else %}no{% endif %}`,
+ map[string]any{"v": "x\ty"})
+}
+
+// H3 — assign escape sequence then use
+func TestS4_Escape_AssignAndOutput(t *testing.T) {
+ s4eq(t, "line1\nline2", `{% assign v = "line1\nline2" %}{{ v }}`, nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// I. Logical operators (and / or) — right-associativity and section-4 operands
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS4_Logic_FalseOrTrue(t *testing.T) {
+ s4eq(t, "yes", `{% if false or true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_TrueAndFalse(t *testing.T) {
+ s4eq(t, "no", `{% if true and false %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_RightAssoc_OrAndOr(t *testing.T) {
+ // true or false and false
+ // right-assoc: true or (false and false) = true or false = true
+ s4eq(t, "yes", `{% if true or false and false %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_RightAssoc_FourTerms(t *testing.T) {
+ // true and false and false or true
+ // right-assoc: true and (false and (false or true)) = true and (false and true) = true and false = false
+ s4eq(t, "no", `{% if true and false and false or true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_RangeContainsInOr(t *testing.T) {
+ // (1..5) contains 3 or false = true or false = true
+ s4eq(t, "yes", `{% if (1..5) contains 3 or false %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_RangeContainsInAnd(t *testing.T) {
+ // (1..5) contains 3 and true = true and true = true
+ s4eq(t, "yes", `{% if (1..5) contains 3 and true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_EmptyInOr(t *testing.T) {
+ // "" == empty or false = true or false = true
+ s4eq(t, "yes", `{% if "" == empty or false %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_BlankInAnd(t *testing.T) {
+ // nil == blank and true = true and true = true
+ s4eq(t, "yes", `{% if nil == blank and true %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Logic_NilOrderingInOr(t *testing.T) {
+ // null < 0 = false; or true = true
+ s4eq(t, "yes", `{% if null < 0 or true %}yes{% else %}no{% endif %}`, nil)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// J. Integration — multiple section 4 features in one template
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS4_Integration_RangeContainsGate(t *testing.T) {
+ // Use range contains to filter output
+ out := s4render(t,
+ `{% for i in (1..5) %}{% if (2..4) contains i %}{{ i }}{% endif %}{% endfor %}`,
+ nil)
+ require.Equal(t, "234", out)
+}
+
+func TestS4_Integration_NotEmptyAndRange(t *testing.T) {
+ // Only output if items is not empty and count is in range
+ tpl := `{% if items != empty and (1..10) contains items.size %}ok{% else %}bad{% endif %}`
+ s4eq(t, "ok", tpl, map[string]any{"items": []any{1, 2, 3}})
+ s4eq(t, "bad", tpl, map[string]any{"items": []any{}})
+}
+
+func TestS4_Integration_BlankFallbackWithDefault(t *testing.T) {
+ // blank binding → default filter activates
+ s4eq(t, "anonymous",
+ `{{ name | default: "anonymous" }}`,
+ map[string]any{"name": ""})
+}
+
+func TestS4_Integration_NilNullAlias(t *testing.T) {
+ // nil and null are interchangeable in same template
+ s4eq(t, "equal",
+ `{% if null == nil %}equal{% else %}not equal{% endif %}`,
+ nil)
+}
+
+func TestS4_Integration_EscapeInOutput(t *testing.T) {
+ // String with escape sequence piped through filter
+ s4eq(t, "LINE1 LINE2",
+ `{{ "line1\nline2" | upcase | replace: "\n", " " }}`,
+ nil)
+}
+
+func TestS4_Integration_CaseWhenRange(t *testing.T) {
+ // case/when with literal values (not range contains — case doesn't use contains)
+ out := s4render(t,
+ `{% case v %}{% when 1 %}one{% when 2 %}two{% when 3 %}three{% else %}other{% endcase %}`,
+ map[string]any{"v": 2})
+ require.Equal(t, "two", out)
+}
+
+func TestS4_Integration_AssignEscapedAndCompare(t *testing.T) {
+ // Assign escape sequence then compare
+ s4eq(t, "yes",
+ `{% assign newline = "\n" %}{% if newline == "\n" %}yes{% else %}no{% endif %}`,
+ nil)
+}
+
+func TestS4_Integration_RangeForLoopWithNotEmpty(t *testing.T) {
+ // Loop over range, only print items whose string is not empty
+ out := s4render(t,
+ `{% for i in (1..3) %}{% assign s = i | append: "" %}{% if s != empty %}[{{ s }}]{% endif %}{% endfor %}`,
+ nil)
+ require.Equal(t, "[1][2][3]", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// K. Edge cases — unless, case/when, captures, nested loops
+// ═════════════════════════════════════════════════════════════════════════════
+
+func TestS4_Edge_UnlessEmpty(t *testing.T) {
+ // unless empty string == empty → unless true → don't render
+ s4eq(t, "",
+ `{% unless v == empty %}show{% endunless %}`,
+ map[string]any{"v": ""})
+}
+
+func TestS4_Edge_UnlessNonEmpty(t *testing.T) {
+ s4eq(t, "show",
+ `{% unless v == empty %}show{% endunless %}`,
+ map[string]any{"v": "hi"})
+}
+
+func TestS4_Edge_CaseWhenWithBlank(t *testing.T) {
+ s4eq(t, "blank case",
+ `{% case v %}{% when blank %}blank case{% when "" %}empty string{% else %}other{% endcase %}`,
+ map[string]any{"v": nil}) // nil is blank
+}
+
+func TestS4_Edge_NestedRangeContains(t *testing.T) {
+ // Inner loop using range contains as filter
+ out := s4render(t, `{% for i in (1..5) %}{% if (2..4) contains i %}{{ i }}{% endif %}{% endfor %}`, nil)
+ require.Equal(t, "234", out)
+}
+
+func TestS4_Edge_RangeInCapture(t *testing.T) {
+ // Capture from a range-driven for loop
+ out := s4render(t,
+ `{% capture result %}{% for i in (1..3) %}{{ i }}{% unless forloop.last %},{% endunless %}{% endfor %}{% endcapture %}{{ result }}`,
+ nil)
+ require.Equal(t, "1,2,3", out)
+}
+
+func TestS4_Edge_DiamondInElsif(t *testing.T) {
+ tpl := `{% if v == 1 %}one{% elsif v <> 2 %}not two{% else %}two{% endif %}`
+ s4eq(t, "not two", tpl, map[string]any{"v": 3})
+ s4eq(t, "two", tpl, map[string]any{"v": 2})
+}
+
+func TestS4_Edge_BlankEmpty_ChainedCheck(t *testing.T) {
+ // Distinguish between blank and empty: whitespace is blank but not empty
+ tpl := `{% if v == blank and v != empty %}only blank{% elsif v == empty %}empty{% else %}other{% endif %}`
+ // " " is blank but NOT empty (has length > 0)
+ s4eq(t, "only blank", tpl, map[string]any{"v": " "})
+ // "" is both blank and empty
+ s4eq(t, "empty", tpl, map[string]any{"v": ""})
+ // "hi" is neither
+ s4eq(t, "other", tpl, map[string]any{"v": "hi"})
+}
+
+func TestS4_Edge_NilOrderingShortCircuit(t *testing.T) {
+ // Nil ordering returns false; should not cause render error
+ out, err := s4renderErr(t, `{% if nil < nil %}y{% else %}n{% endif %}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "n", out)
+}
+
+func TestS4_Edge_RangeContainsZero(t *testing.T) {
+ // Boundary: 0 in range that spans 0
+ s4eq(t, "yes", `{% if (-1..1) contains 0 %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Edge_LargeRange(t *testing.T) {
+ // Large range — contains should be O(1), not iterate
+ out, err := s4renderErr(t, `{% if (1..1000) contains 999 %}yes{% else %}no{% endif %}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "yes", out)
+}
+
+func TestS4_Edge_NotInConditionChain(t *testing.T) {
+ // Realistic: show element only if not in "skip" range
+ tpl := `{% for i in (1..6) %}{% if not (3..4) contains i %}{{ i }}{% endif %}{% endfor %}`
+ s4eq(t, "1256", tpl, nil)
+}
+
+func TestS4_Edge_EmptyAfterAssign_Nil(t *testing.T) {
+ // Assign nil-valued expression then check empty
+ // nil is not empty (it's blank but not empty)
+ s4eq(t, "no", `{% assign v = nothing %}{% if v == empty %}yes{% else %}no{% endif %}`, nil)
+}
+
+func TestS4_Edge_BlankAfterCapture_Empty(t *testing.T) {
+ // capture nothing → "" → blank AND empty
+ s4eq(t, "blank", `{% capture v %}{% endcapture %}{% if v == blank %}blank{% else %}not blank{% endif %}`, nil)
+}
diff --git a/s5_variable_access_e2e_test.go b/s5_variable_access_e2e_test.go
new file mode 100644
index 00000000..6203872a
--- /dev/null
+++ b/s5_variable_access_e2e_test.go
@@ -0,0 +1,1516 @@
+package liquid_test
+
+// S5 — Variable Access: intensive E2E tests
+//
+// Covers topic 5 of the implementation-checklist:
+//
+// 5a. obj.prop, obj[key], array[0]
+// 5b. array[-1] — negative indexing
+// 5c. array.first, array.last, obj.size
+// 5d. {{ [key] }} — dynamic variable lookup (Ruby)
+// 5e. {{ test . test }} — dot with surrounding whitespace (Ruby)
+// 5f. {{ ["Key"].sub }} — top-level bracket + dot (LiquidJS #643)
+//
+// Goal: cover all edge cases so that any regression in the
+// binding→parser→evaluator→render pipeline is detected immediately.
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/osteele/liquid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// renderS5 is the shared helper.
+func renderS5(t *testing.T, tpl string, bindings map[string]any) string {
+ t.Helper()
+ eng := liquid.NewEngine()
+ out, err := eng.ParseAndRenderString(tpl, bindings)
+ require.NoError(t, err, "template: %s", tpl)
+ return out
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ 5a — obj.prop, obj[key], array[0] ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+// ── 5a.1: Dot notation ────────────────────────────────────────────────────────
+
+func TestS5_DotNotation_SingleLevel(t *testing.T) {
+ out := renderS5(t, `{{ obj.name }}`, map[string]any{
+ "obj": map[string]any{"name": "Alice"},
+ })
+ require.Equal(t, "Alice", out)
+}
+
+func TestS5_DotNotation_TwoLevels(t *testing.T) {
+ out := renderS5(t, `{{ a.b.c }}`, map[string]any{
+ "a": map[string]any{"b": map[string]any{"c": "deep"}},
+ })
+ require.Equal(t, "deep", out)
+}
+
+func TestS5_DotNotation_FiveLevels(t *testing.T) {
+ out := renderS5(t, `{{ a.b.c.d.e }}`, map[string]any{
+ "a": map[string]any{
+ "b": map[string]any{
+ "c": map[string]any{
+ "d": map[string]any{
+ "e": "leaf",
+ },
+ },
+ },
+ },
+ })
+ require.Equal(t, "leaf", out)
+}
+
+func TestS5_DotNotation_MissingKeyReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ obj.missing }}`, map[string]any{
+ "obj": map[string]any{"name": "Alice"},
+ })
+ require.Equal(t, "", out)
+}
+
+func TestS5_DotNotation_MidChainMissing_StopsGracefully(t *testing.T) {
+ // obj.b doesn't exist; obj.b.c must not panic
+ out := renderS5(t, `{{ obj.b.c }}`, map[string]any{
+ "obj": map[string]any{"name": "Alice"},
+ })
+ require.Equal(t, "", out)
+}
+
+func TestS5_DotNotation_OnNilVariable(t *testing.T) {
+ out := renderS5(t, `{{ nothing.prop }}`, map[string]any{"nothing": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS5_DotNotation_OnGoStruct(t *testing.T) {
+ type Inner struct{ Value string }
+ type Outer struct{ Inner Inner }
+
+ out := renderS5(t, `{{ obj.Inner.Value }}`, map[string]any{
+ "obj": Outer{Inner: Inner{Value: "struct_leaf"}},
+ })
+ require.Equal(t, "struct_leaf", out)
+}
+
+func TestS5_DotNotation_GoStructPublicFields(t *testing.T) {
+ type Person struct {
+ Name string
+ Age int
+ }
+ out := renderS5(t, `{{ p.Name }} is {{ p.Age }}`, map[string]any{
+ "p": Person{Name: "Bob", Age: 30},
+ })
+ require.Equal(t, "Bob is 30", out)
+}
+
+// ── 5a.2: Bracket notation with string keys ───────────────────────────────────
+
+func TestS5_BracketString_SingleKey(t *testing.T) {
+ out := renderS5(t, `{{ page["title"] }}`, map[string]any{
+ "page": map[string]any{"title": "Intro"},
+ })
+ require.Equal(t, "Intro", out)
+}
+
+func TestS5_BracketString_KeyWithSpaces(t *testing.T) {
+ out := renderS5(t, `{{ hash["complex key"] }}`, map[string]any{
+ "hash": map[string]any{"complex key": "found"},
+ })
+ require.Equal(t, "found", out)
+}
+
+func TestS5_BracketString_KeyWithSpecialChars(t *testing.T) {
+ out := renderS5(t, `{{ data["key-with-dashes"] }}`, map[string]any{
+ "data": map[string]any{"key-with-dashes": "val"},
+ })
+ require.Equal(t, "val", out)
+}
+
+func TestS5_BracketVar_KeyFromVariable(t *testing.T) {
+ // {{ a[b] }} — key is a variable
+ out := renderS5(t, `{{ a[b] }}`, map[string]any{
+ "b": "c",
+ "a": map[string]any{"c": "result"},
+ })
+ require.Equal(t, "result", out)
+}
+
+func TestS5_BracketVar_KeyFromVariableWithSpaces(t *testing.T) {
+ // Explicit space around inner variable: {{ a[ b ] }}
+ out := renderS5(t, `{{ a[ b ] }}`, map[string]any{
+ "b": "k",
+ "a": map[string]any{"k": "found"},
+ })
+ require.Equal(t, "found", out)
+}
+
+func TestS5_BracketMixed_DotThenBracket(t *testing.T) {
+ // {{ hash["b"].c }} — bracket then dot
+ out := renderS5(t, `{{ hash["b"].c }}`, map[string]any{
+ "hash": map[string]any{
+ "b": map[string]any{"c": "d"},
+ },
+ })
+ require.Equal(t, "d", out)
+}
+
+func TestS5_BracketMixed_DotThenBracketThenDot(t *testing.T) {
+ out := renderS5(t, `{{ obj.a["b"].c }}`, map[string]any{
+ "obj": map[string]any{
+ "a": map[string]any{
+ "b": map[string]any{"c": "xyz"},
+ },
+ },
+ })
+ require.Equal(t, "xyz", out)
+}
+
+// ── 5a.3: Array integer indexing ──────────────────────────────────────────────
+
+func TestS5_ArrayIndex_First(t *testing.T) {
+ out := renderS5(t, `{{ arr[0] }}`, map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "a", out)
+}
+
+func TestS5_ArrayIndex_Middle(t *testing.T) {
+ out := renderS5(t, `{{ arr[1] }}`, map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "b", out)
+}
+
+func TestS5_ArrayIndex_Last(t *testing.T) {
+ out := renderS5(t, `{{ arr[2] }}`, map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "c", out)
+}
+
+func TestS5_ArrayIndex_OutOfBounds_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ arr[99] }}`, map[string]any{"arr": []string{"a", "b"}})
+ require.Equal(t, "", out)
+}
+
+func TestS5_ArrayIndex_ViaVariable(t *testing.T) {
+ out := renderS5(t, `{{ arr[i] }}`, map[string]any{"arr": []string{"x", "y", "z"}, "i": 2})
+ require.Equal(t, "z", out)
+}
+
+func TestS5_ArrayIndex_ViaAssign(t *testing.T) {
+ out := renderS5(t,
+ `{% assign i = 1 %}{{ arr[i] }}`,
+ map[string]any{"arr": []string{"first", "second", "third"}})
+ require.Equal(t, "second", out)
+}
+
+func TestS5_ArrayIndex_NestedArrays(t *testing.T) {
+ out := renderS5(t, `{{ matrix[1][0] }}`, map[string]any{
+ "matrix": [][]string{{"a", "b"}, {"c", "d"}},
+ })
+ require.Equal(t, "c", out)
+}
+
+func TestS5_ArrayIndex_InsideForLoop(t *testing.T) {
+ // access a specific index via a range variable inside a for loop
+ out := renderS5(t,
+ `{% for i in (0..2) %}{{ arr[i] }}{% endfor %}`,
+ map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "xyz", out)
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ 5b — Negative array indexing ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+func TestS5_NegativeIndex_MinusOne(t *testing.T) {
+ out := renderS5(t, `{{ arr[-1] }}`, map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "z", out)
+}
+
+func TestS5_NegativeIndex_MinusTwo(t *testing.T) {
+ out := renderS5(t, `{{ arr[-2] }}`, map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "y", out)
+}
+
+func TestS5_NegativeIndex_MinusLen_IsFirst(t *testing.T) {
+ out := renderS5(t, `{{ arr[-3] }}`, map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "x", out)
+}
+
+func TestS5_NegativeIndex_BeyondLength_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ arr[-8] }}`, map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "", out)
+}
+
+func TestS5_NegativeIndex_EmptyArray_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ arr[-1] }}`, map[string]any{"arr": []string{}})
+ require.Equal(t, "", out)
+}
+
+func TestS5_NegativeIndex_SingleElement(t *testing.T) {
+ // [-1] on a single-element array == [0]
+ out := renderS5(t, `{{ arr[-1] }}`, map[string]any{"arr": []string{"only"}})
+ require.Equal(t, "only", out)
+}
+
+func TestS5_NegativeIndex_ViaAssign(t *testing.T) {
+ // split produces []string; negative index must work
+ out := renderS5(t,
+ `{% assign a = "x,y,z" | split: ',' %}{{ a[-1] }} {{ a[-3] }} {{ a[-8] }}`,
+ nil)
+ require.Equal(t, "z x ", out)
+}
+
+func TestS5_NegativeIndex_PositiveNegativeEquivalence(t *testing.T) {
+ // arr[-1] == arr[len-1]
+ arr := []string{"alpha", "beta", "gamma"}
+ eng := liquid.NewEngine()
+
+ v1, _ := eng.ParseAndRenderString(`{{ arr[-1] }}`, map[string]any{"arr": arr})
+ v2, _ := eng.ParseAndRenderString(`{{ arr[2] }}`, map[string]any{"arr": arr})
+ require.Equal(t, v1, v2, "arr[-1] must equal arr[len-1]")
+
+ v3, _ := eng.ParseAndRenderString(`{{ arr[-2] }}`, map[string]any{"arr": arr})
+ v4, _ := eng.ParseAndRenderString(`{{ arr[1] }}`, map[string]any{"arr": arr})
+ require.Equal(t, v3, v4, "arr[-2] must equal arr[len-2]")
+}
+
+func TestS5_NegativeIndex_IntegerTypesAsIndex(t *testing.T) {
+ arr := []string{"a", "b", "c"}
+ // int, int8, int16, int32, int64 — all must work as negative indices
+ cases := []any{
+ int(-1), int8(-1), int16(-1), int32(-1), int64(-1),
+ }
+ for _, idx := range cases {
+ t.Run(fmt.Sprintf("%T", idx), func(t *testing.T) {
+ out := renderS5(t, `{{ arr[i] }}`, map[string]any{"arr": arr, "i": idx})
+ require.Equal(t, "c", out)
+ })
+ }
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ 5c — array.first · array.last · obj.size ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+// ── 5c.1: .first ──────────────────────────────────────────────────────────────
+
+func TestS5_First_OnArray(t *testing.T) {
+ out := renderS5(t, `{{ arr.first }}`, map[string]any{"arr": []string{"apple", "banana", "cherry"}})
+ require.Equal(t, "apple", out)
+}
+
+func TestS5_First_OnSingleElement(t *testing.T) {
+ out := renderS5(t, `{{ arr.first }}`, map[string]any{"arr": []string{"solo"}})
+ require.Equal(t, "solo", out)
+}
+
+func TestS5_First_OnEmpty_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ arr.first }}`, map[string]any{"arr": []string{}})
+ require.Equal(t, "", out)
+}
+
+func TestS5_First_EqualsIndex0(t *testing.T) {
+ eng := liquid.NewEngine()
+ arr := []string{"alpha", "beta"}
+ v1, _ := eng.ParseAndRenderString(`{{ arr.first }}`, map[string]any{"arr": arr})
+ v2, _ := eng.ParseAndRenderString(`{{ arr[0] }}`, map[string]any{"arr": arr})
+ require.Equal(t, v1, v2)
+}
+
+func TestS5_First_OnIntArray(t *testing.T) {
+ out := renderS5(t, `{{ nums.first }}`, map[string]any{"nums": []int{10, 20, 30}})
+ require.Equal(t, "10", out)
+}
+
+// ── 5c.2: .last ───────────────────────────────────────────────────────────────
+
+func TestS5_Last_OnArray(t *testing.T) {
+ out := renderS5(t, `{{ arr.last }}`, map[string]any{"arr": []string{"apple", "banana", "cherry"}})
+ require.Equal(t, "cherry", out)
+}
+
+func TestS5_Last_OnSingleElement(t *testing.T) {
+ out := renderS5(t, `{{ arr.last }}`, map[string]any{"arr": []string{"solo"}})
+ require.Equal(t, "solo", out)
+}
+
+func TestS5_Last_OnEmpty_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ arr.last }}`, map[string]any{"arr": []string{}})
+ require.Equal(t, "", out)
+}
+
+func TestS5_Last_EqualsNegativeOne(t *testing.T) {
+ eng := liquid.NewEngine()
+ arr := []string{"alpha", "beta", "gamma"}
+ v1, _ := eng.ParseAndRenderString(`{{ arr.last }}`, map[string]any{"arr": arr})
+ v2, _ := eng.ParseAndRenderString(`{{ arr[-1] }}`, map[string]any{"arr": arr})
+ require.Equal(t, v1, v2, "arr.last must equal arr[-1]")
+}
+
+func TestS5_Last_OnIntArray(t *testing.T) {
+ out := renderS5(t, `{{ nums.last }}`, map[string]any{"nums": []int{10, 20, 30}})
+ require.Equal(t, "30", out)
+}
+
+// ── 5c.3: .size ───────────────────────────────────────────────────────────────
+
+func TestS5_Size_OnStringArray(t *testing.T) {
+ out := renderS5(t, `{{ arr.size }}`, map[string]any{"arr": []string{"a", "b", "c", "d"}})
+ require.Equal(t, "4", out)
+}
+
+func TestS5_Size_OnEmptyArray(t *testing.T) {
+ out := renderS5(t, `{{ arr.size }}`, map[string]any{"arr": []string{}})
+ require.Equal(t, "0", out)
+}
+
+func TestS5_Size_OnString_IsRuneCount(t *testing.T) {
+ out := renderS5(t, `{{ s.size }}`, map[string]any{"s": "hello"})
+ require.Equal(t, "5", out)
+}
+
+func TestS5_Size_OnString_Multibyte(t *testing.T) {
+ // Unicode string: rune count, not byte count
+ out := renderS5(t, `{{ s.size }}`, map[string]any{"s": "héllo"})
+ require.Equal(t, "5", out)
+}
+
+func TestS5_Size_OnMap(t *testing.T) {
+ out := renderS5(t, `{{ h.size }}`, map[string]any{
+ "h": map[string]any{"a": 1, "b": 2, "c": 3},
+ })
+ require.Equal(t, "3", out)
+}
+
+func TestS5_Size_OnEmptyMap(t *testing.T) {
+ out := renderS5(t, `{{ h.size }}`, map[string]any{"h": map[string]any{}})
+ require.Equal(t, "0", out)
+}
+
+func TestS5_Size_MapKeyWinsOverBuiltin(t *testing.T) {
+ // When a map has an explicit "size" key, that value wins over the computed count
+ out := renderS5(t, `{{ h.size }}`, map[string]any{
+ "h": map[string]any{"size": "custom"},
+ })
+ require.Equal(t, "custom", out)
+}
+
+func TestS5_Size_InCondition(t *testing.T) {
+ out := renderS5(t,
+ `{% if arr.size > 2 %}big{% else %}small{% endif %}`,
+ map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "big", out)
+}
+
+func TestS5_Size_UsedInFilterChain(t *testing.T) {
+ out := renderS5(t, `{{ arr.size | plus: 10 }}`, map[string]any{"arr": []string{"a", "b"}})
+ require.Equal(t, "12", out)
+}
+
+func TestS5_First_UsedInFilterChain(t *testing.T) {
+ out := renderS5(t, `{{ arr.first | upcase }}`, map[string]any{"arr": []string{"hello", "world"}})
+ require.Equal(t, "HELLO", out)
+}
+
+func TestS5_First_NestedAccess(t *testing.T) {
+ // arr.first.name — first returns an object, then access .name
+ out := renderS5(t, `{{ people.first.name }}`, map[string]any{
+ "people": []map[string]any{
+ {"name": "Alice", "age": 30},
+ {"name": "Bob", "age": 25},
+ },
+ })
+ require.Equal(t, "Alice", out)
+}
+
+func TestS5_Last_NestedAccess(t *testing.T) {
+ out := renderS5(t, `{{ people.last.name }}`, map[string]any{
+ "people": []map[string]any{
+ {"name": "Alice"},
+ {"name": "Bob"},
+ },
+ })
+ require.Equal(t, "Bob", out)
+}
+
+func TestS5_Size_InsideForLoop(t *testing.T) {
+ out := renderS5(t,
+ `{% for item in items %}{{ forloop.index }}/{{ items.size }} {% endfor %}`,
+ map[string]any{"items": []string{"a", "b", "c"}})
+ require.Equal(t, "1/3 2/3 3/3 ", out)
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ 5d — {{ [key] }} dynamic variable lookup ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+func TestS5_DynamicLookup_Simple(t *testing.T) {
+ out := renderS5(t, `{{ [key] }}`, map[string]any{"key": "foo", "foo": "bar"})
+ require.Equal(t, "bar", out)
+}
+
+func TestS5_DynamicLookup_MissingKey_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ [key] }}`, map[string]any{"key": "nonexistent"})
+ require.Equal(t, "", out)
+}
+
+func TestS5_DynamicLookup_KeyIsNil_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ [key] }}`, map[string]any{"key": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS5_DynamicLookup_WithSingleQuoteKey(t *testing.T) {
+ // {{ ['foo'] }} — single-quoted string literal in brackets at top level
+ out := renderS5(t, "{{ ['foo'] }}", map[string]any{"foo": "sq_direct"})
+ require.Equal(t, "sq_direct", out)
+}
+
+func TestS5_DynamicLookup_KeyFromAssign(t *testing.T) {
+ out := renderS5(t,
+ `{% assign k = "target" %}{{ [k] }}`,
+ map[string]any{"target": "resolved"})
+ require.Equal(t, "resolved", out)
+}
+
+func TestS5_DynamicLookup_KeyFromArrayIndex(t *testing.T) {
+ // {{ [list[0]] }} — use list[0] as the variable name
+ out := renderS5(t, `{{ [list[0]] }}`, map[string]any{
+ "list": []string{"foo"},
+ "foo": "bar",
+ })
+ require.Equal(t, "bar", out)
+}
+
+func TestS5_DynamicLookup_NestedResult_AccessProperty(t *testing.T) {
+ // {{ [key].name }} — resolved value is an object, then access property
+ out := renderS5(t, `{{ [varname].name }}`, map[string]any{
+ "varname": "person",
+ "person": map[string]any{"name": "Alice"},
+ })
+ require.Equal(t, "Alice", out)
+}
+
+func TestS5_DynamicLookup_DoubleNested(t *testing.T) {
+ // {{ list[list[0]]["foo"] }} — chain of lookups where an index is itself
+ // the result of another index operation
+ out := renderS5(t, `{{ list[list[0]]["foo"] }}`, map[string]any{
+ "list": []any{1, map[string]any{"foo": "bar"}},
+ })
+ require.Equal(t, "bar", out)
+}
+
+func TestS5_DynamicLookup_InsideForLoop(t *testing.T) {
+ // Iterates over a list of variable names and resolves each dynamically
+ out := renderS5(t,
+ `{% for k in keys %}{{ [k] }} {% endfor %}`,
+ map[string]any{
+ "keys": []string{"a", "b", "c"},
+ "a": "alpha",
+ "b": "beta",
+ "c": "gamma",
+ })
+ require.Equal(t, "alpha beta gamma ", out)
+}
+
+func TestS5_DynamicLookup_InsideIf(t *testing.T) {
+ out := renderS5(t,
+ `{% if [flag] %}yes{% else %}no{% endif %}`,
+ map[string]any{"flag": "enabled", "enabled": true})
+ require.Equal(t, "yes", out)
+}
+
+func TestS5_DynamicLookup_WithLiteralStringKey(t *testing.T) {
+ // {{ ["foo"] }} — literal string in brackets at top level → lookup "foo"
+ out := renderS5(t, `{{ ["foo"] }}`, map[string]any{"foo": "direct"})
+ require.Equal(t, "direct", out)
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ 5e — dot with surrounding whitespace ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+func TestS5_DotWithSpaces_Single(t *testing.T) {
+ out := renderS5(t, `{{ obj . key }}`, map[string]any{
+ "obj": map[string]any{"key": "found"},
+ })
+ require.Equal(t, "found", out)
+}
+
+func TestS5_DotWithSpaces_TwoLevels(t *testing.T) {
+ out := renderS5(t, `{{ a . b . c }}`, map[string]any{
+ "a": map[string]any{
+ "b": map[string]any{"c": "deep"},
+ },
+ })
+ require.Equal(t, "deep", out)
+}
+
+func TestS5_DotWithSpaces_MixedWithNormalDot(t *testing.T) {
+ // mix: first level with spaces, second without
+ out := renderS5(t, `{{ a . b.c }}`, map[string]any{
+ "a": map[string]any{
+ "b": map[string]any{"c": "mixed"},
+ },
+ })
+ require.Equal(t, "mixed", out)
+}
+
+func TestS5_DotWithSpaces_InFilter(t *testing.T) {
+ out := renderS5(t, `{{ obj . name | upcase }}`, map[string]any{
+ "obj": map[string]any{"name": "hello"},
+ })
+ require.Equal(t, "HELLO", out)
+}
+
+func TestS5_DotWithSpaces_InCondition(t *testing.T) {
+ out := renderS5(t,
+ `{% if obj . active %}yes{% else %}no{% endif %}`,
+ map[string]any{"obj": map[string]any{"active": true}})
+ require.Equal(t, "yes", out)
+}
+
+func TestS5_DotWithSpaces_WithTabs(t *testing.T) {
+ // scanner must skip all whitespace (including tabs) around the dot
+ out := renderS5(t, "{{ obj\t.\tkey }}", map[string]any{
+ "obj": map[string]any{"key": "tab-spaced"},
+ })
+ require.Equal(t, "tab-spaced", out)
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ 5f — top-level bracket + dot access (LiquidJS #643) ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+func TestS5_BracketRoot_SimpleDotAccess(t *testing.T) {
+ out := renderS5(t, `{{ ["Key String with Spaces"].subpropertyKey }}`, map[string]any{
+ "Key String with Spaces": map[string]any{"subpropertyKey": "FOO"},
+ })
+ require.Equal(t, "FOO", out)
+}
+
+func TestS5_BracketRoot_ChainedDots(t *testing.T) {
+ out := renderS5(t, `{{ ["root key"].a.b }}`, map[string]any{
+ "root key": map[string]any{
+ "a": map[string]any{"b": "nested"},
+ },
+ })
+ require.Equal(t, "nested", out)
+}
+
+func TestS5_BracketRoot_WithBracketThenDot(t *testing.T) {
+ out := renderS5(t, `{{ ["root"]["inner"].value }}`, map[string]any{
+ "root": map[string]any{
+ "inner": map[string]any{"value": "chained"},
+ },
+ })
+ require.Equal(t, "chained", out)
+}
+
+func TestS5_BracketRoot_InFilter(t *testing.T) {
+ out := renderS5(t, `{{ ["name"] | upcase }}`, map[string]any{"name": "world"})
+ require.Equal(t, "WORLD", out)
+}
+
+func TestS5_BracketRoot_InCondition(t *testing.T) {
+ out := renderS5(t,
+ `{% if ["flag"] %}yes{% else %}no{% endif %}`,
+ map[string]any{"flag": true})
+ require.Equal(t, "yes", out)
+}
+
+func TestS5_BracketRoot_VariableKey(t *testing.T) {
+ // {{ [varname].prop }} — key from variable, then dot
+ out := renderS5(t, `{{ [k].prop }}`, map[string]any{
+ "k": "obj",
+ "obj": map[string]any{"prop": "val"},
+ })
+ require.Equal(t, "val", out)
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ Cross-cutting: interaction between all features ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+func TestS5_CrossCutting_NegIndexThenDot(t *testing.T) {
+ // arr[-1].name — negative index on array of objects, then dot
+ out := renderS5(t, `{{ people[-1].name }}`, map[string]any{
+ "people": []map[string]any{
+ {"name": "Alice"},
+ {"name": "Bob"},
+ },
+ })
+ require.Equal(t, "Bob", out)
+}
+
+func TestS5_CrossCutting_FirstThenIndex(t *testing.T) {
+ // matrix.first[1] - .first returns an array, then index into it
+ out := renderS5(t, `{{ matrix.first[1] }}`, map[string]any{
+ "matrix": [][]string{{"a", "b"}, {"c", "d"}},
+ })
+ require.Equal(t, "b", out)
+}
+
+func TestS5_CrossCutting_DynamicLookupThenNegIndex(t *testing.T) {
+ // {{ [key][-1] }} — resolve variable, then negative index
+ out := renderS5(t, `{{ [key][-1] }}`, map[string]any{
+ "key": "fruits",
+ "fruits": []string{"apple", "banana", "cherry"},
+ })
+ require.Equal(t, "cherry", out)
+}
+
+func TestS5_CrossCutting_DynamicLookupThenFirst(t *testing.T) {
+ out := renderS5(t, `{{ [key].first }}`, map[string]any{
+ "key": "items",
+ "items": []string{"one", "two"},
+ })
+ require.Equal(t, "one", out)
+}
+
+func TestS5_CrossCutting_DynamicLookupThenSize(t *testing.T) {
+ out := renderS5(t, `{{ [key].size }}`, map[string]any{
+ "key": "items",
+ "items": []string{"x", "y", "z"},
+ })
+ require.Equal(t, "3", out)
+}
+
+func TestS5_CrossCutting_AllFeaturesInSingleOutput(t *testing.T) {
+ // Template that exercises all 6 feature areas in one render
+ tpl := strings.Join([]string{
+ `{{ a.b }}`, // 5a: dot notation
+ ` `,
+ `{{ arr[1] }}`, // 5a: array index
+ ` `,
+ `{{ arr[-1] }}`, // 5b: negative index
+ ` `,
+ `{{ arr.first }}`, // 5c: .first
+ ` `,
+ `{{ arr.last }}`, // 5c: .last
+ ` `,
+ `{{ arr.size }}`, // 5c: .size
+ ` `,
+ `{{ [k] }}`, // 5d: dynamic lookup
+ ` `,
+ `{{ a . b }}`, // 5e: dot with spaces
+ ` `,
+ `{{ ["a key"].val }}`, // 5f: bracket root + dot
+ }, "")
+
+ binds := map[string]any{
+ "a": map[string]any{"b": "dot"},
+ "arr": []string{"first_el", "mid_el", "last_el"},
+ "k": "target",
+ "target": "dynamic",
+ "a key": map[string]any{"val": "bracket"},
+ }
+
+ out := renderS5(t, tpl, binds)
+ require.Equal(t, "dot mid_el last_el first_el last_el 3 dynamic dot bracket", out)
+}
+
+// ── Variable types as keys / indices ─────────────────────────────────────────
+
+func TestS5_Unicode_VariableName(t *testing.T) {
+ eng := liquid.NewEngine()
+ out, err := eng.ParseAndRenderString(`{{ÜLKE}}`, map[string]any{"ÜLKE": "Türkiye"})
+ require.NoError(t, err)
+ require.Equal(t, "Türkiye", out)
+}
+
+func TestS5_Unicode_DotAccess(t *testing.T) {
+ out := renderS5(t, `{{ país.capital }}`, map[string]any{
+ "país": map[string]any{"capital": "Madrid"},
+ })
+ require.Equal(t, "Madrid", out)
+}
+
+// ── Blank / empty as variable names ─────────────────────────────────────────
+
+func TestS5_BlankAssigned_RendersEmpty(t *testing.T) {
+ out := renderS5(t, `{% assign v = blank %}{{ v }}`, nil)
+ require.Equal(t, "", out)
+}
+
+func TestS5_EmptyAssigned_RendersEmpty(t *testing.T) {
+ out := renderS5(t, `{% assign v = empty %}{{ v }}`, nil)
+ require.Equal(t, "", out)
+}
+
+func TestS5_BlankAssigned_RendersAsEmptyStringInOutput(t *testing.T) {
+ // After assign v = blank, the variable renders as empty string
+ // (blank is a special sentinel that renders as "").
+ out := renderS5(t, `{% assign v = blank %}[{{ v }}]`, nil)
+ require.Equal(t, "[]", out)
+}
+
+// ── Nil safety ────────────────────────────────────────────────────────────────
+
+func TestS5_NilSafe_DeepChainOnNil(t *testing.T) {
+ // nil variable; deep property chain must not panic
+ out := renderS5(t, `{{ n.a.b.c }}`, map[string]any{"n": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS5_NilSafe_IndexOnNil(t *testing.T) {
+ out := renderS5(t, `{{ n[0] }}`, map[string]any{"n": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS5_NilSafe_NegIndexOnNil(t *testing.T) {
+ out := renderS5(t, `{{ n[-1] }}`, map[string]any{"n": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS5_NilSafe_SpecialPropsOnNil(t *testing.T) {
+ // nil.first / nil.last / nil.size must all render as empty string
+ for _, prop := range []string{"first", "last", "size"} {
+ t.Run(prop, func(t *testing.T) {
+ out := renderS5(t, fmt.Sprintf(`{{ n.%s }}`, prop), map[string]any{"n": nil})
+ require.Equal(t, "", out, "nil.%s should render empty", prop)
+ })
+ }
+}
+
+// ── Rendering false/nil ────────────────────────────────────────────────────────
+
+func TestS5_FalseRendersAsFalse(t *testing.T) {
+ out := renderS5(t, `{{ obj.flag }}`, map[string]any{"obj": map[string]any{"flag": false}})
+ require.Equal(t, "false", out)
+}
+
+func TestS5_NilRendersEmpty(t *testing.T) {
+ out := renderS5(t, `{{ obj.missing }}`, map[string]any{"obj": map[string]any{}})
+ require.Equal(t, "", out)
+}
+
+// ── Regression: multiline tags ────────────────────────────────────────────────
+
+func TestS5_MultilineTag_DotAccess(t *testing.T) {
+ out := renderS5(t, "{{\nobj.key\n}}", map[string]any{
+ "obj": map[string]any{"key": "multiline"},
+ })
+ require.Equal(t, "multiline", out)
+}
+
+func TestS5_MultilineTag_NegIndex(t *testing.T) {
+ out := renderS5(t, "{{\narr[-1]\n}}", map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "c", out)
+}
+
+// ── StrictVariables compatibility ─────────────────────────────────────────────
+
+func TestS5_StrictVariables_DotAccessOnUndefined(t *testing.T) {
+ // accessing .prop on an undefined root variable should error in strict mode
+ eng := liquid.NewEngine()
+ eng.StrictVariables()
+ _, err := eng.ParseAndRenderString(`{{ undefined.prop }}`, nil)
+ assert.Error(t, err)
+}
+
+func TestS5_StrictVariables_DynamicLookupKeyExists(t *testing.T) {
+ // In strict mode, the outer key variable must exist; the lookup itself works.
+ // Note: strict mode does NOT propagate through double-indirection — the
+ // resolved variable 'ghost' not existing does NOT produce an error because
+ // at render time the nil result is indistinguishable from a missing property.
+ eng := liquid.NewEngine()
+ eng.StrictVariables()
+ _, err := eng.ParseAndRenderString(`{{ [key] }}`, map[string]any{"key": "ghost"})
+ // No error: dynamic lookup returns nil for missing resolved variable (spec behavior)
+ assert.NoError(t, err)
+}
+
+// ╔══════════════════════════════════════════════════════════════════════════════╗
+// ║ INTENSIVE BLOCK — regression traps & advanced scenarios ║
+// ╚══════════════════════════════════════════════════════════════════════════════╝
+
+// ── Uint types as index (regression: B1 fix must cover negative too) ─────────
+
+func TestS5_NegativeIndex_UintTypesAsIndex(t *testing.T) {
+ // uint variants used as *negative* index — they're positive, so must work as
+ // unsigned positive indices (uint(2) → index 2, not -1).
+ arr := []string{"a", "b", "c"}
+ cases := []struct {
+ idx any
+ expected string
+ name string
+ }{
+ {uint(0), "a", "uint(0)"},
+ {uint8(1), "b", "uint8(1)"},
+ {uint16(2), "c", "uint16(2)"},
+ {uint32(0), "a", "uint32(0)"},
+ {uint64(2), "c", "uint64(2)"},
+ {uintptr(1), "b", "uintptr(1)"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ out := renderS5(t, `{{ arr[i] }}`, map[string]any{"arr": arr, "i": tc.idx})
+ require.Equal(t, tc.expected, out)
+ })
+ }
+}
+
+// ── Go struct: pointer, embedded, unexported ──────────────────────────────────
+
+func TestS5_GoStruct_PointerField(t *testing.T) {
+ type Inner struct{ Title string }
+ type Page struct{ Content *Inner }
+
+ inner := Inner{Title: "ptr-value"}
+ out := renderS5(t, `{{ page.Content.Title }}`, map[string]any{
+ "page": Page{Content: &inner},
+ })
+ require.Equal(t, "ptr-value", out)
+}
+
+func TestS5_GoStruct_NilPointerField_Graceful(t *testing.T) {
+ type Inner struct{ Title string }
+ type Page struct{ Content *Inner }
+
+ out := renderS5(t, `{{ page.Content.Title }}`, map[string]any{
+ "page": Page{Content: nil},
+ })
+ require.Equal(t, "", out)
+}
+
+func TestS5_GoStruct_EmbeddedStruct(t *testing.T) {
+ type Base struct{ ID int }
+ type Product struct {
+ Base
+ Name string
+ }
+ out := renderS5(t, `{{ product.Name }} {{ product.ID }}`, map[string]any{
+ "product": Product{Base: Base{ID: 42}, Name: "Widget"},
+ })
+ require.Equal(t, "Widget 42", out)
+}
+
+func TestS5_GoStruct_MissingField_Inaccessible(t *testing.T) {
+ // Accessing a key that doesn't exist on the struct renders empty.
+ type Obj struct{ Pub string }
+ out := renderS5(t, `[{{ obj.Pub }}][{{ obj.absent }}]`, map[string]any{
+ "obj": Obj{Pub: "yes"},
+ })
+ require.Equal(t, "[yes][]", out)
+}
+
+func TestS5_GoStruct_SliceOfStructs(t *testing.T) {
+ type Item struct{ Name string }
+ items := []Item{{"alpha"}, {"beta"}, {"gamma"}}
+ out := renderS5(t,
+ `{% for it in items %}{{ it.Name }} {% endfor %}`,
+ map[string]any{"items": items})
+ require.Equal(t, "alpha beta gamma ", out)
+}
+
+func TestS5_GoStruct_MapOfStructs_DotAccess(t *testing.T) {
+ type Info struct{ Score int }
+ out := renderS5(t, `{{ data.alice.Score }}`, map[string]any{
+ "data": map[string]any{"alice": struct{ Score int }{Score: 99}},
+ })
+ require.Equal(t, "99", out)
+}
+
+// ── Negative index in conditions ─────────────────────────────────────────────
+
+func TestS5_NegativeIndex_InIfCondition(t *testing.T) {
+ out := renderS5(t,
+ `{% if arr[-1] == "last" %}yes{% else %}no{% endif %}`,
+ map[string]any{"arr": []string{"first", "last"}})
+ require.Equal(t, "yes", out)
+}
+
+func TestS5_NegativeIndex_InCondition_EmptyArray_NoError(t *testing.T) {
+ // On empty array, arr[-1] returns nil — must not error in if-condition
+ out := renderS5(t,
+ `{% if arr[-1] == "x" %}yes{% else %}no{% endif %}`,
+ map[string]any{"arr": []string{}})
+ require.Equal(t, "no", out)
+}
+
+func TestS5_NegativeIndex_InUnless(t *testing.T) {
+ // arr[-1]=="c", != "z" → condition false → unless body executes → "no-z"
+ out := renderS5(t,
+ `{% unless arr[-1] == "z" %}no-z{% endunless %}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "no-z", out)
+}
+
+func TestS5_NegativeIndex_InsideCaptureAndAssign(t *testing.T) {
+ // {% assign x = arr[-1] %} then render x
+ out := renderS5(t,
+ `{% assign last = arr[-1] %}{{ last }}`,
+ map[string]any{"arr": []string{"x", "y", "final"}})
+ require.Equal(t, "final", out)
+}
+
+func TestS5_NegativeIndex_UsedAsSplitResult(t *testing.T) {
+ // After split, negative index on assigned array
+ out := renderS5(t,
+ `{% assign parts = "a|b|c|d" | split: "|" %}{{ parts[-1] }}`,
+ nil)
+ require.Equal(t, "d", out)
+}
+
+// ── .first / .last on complex types ──────────────────────────────────────────
+
+func TestS5_First_OnArrayOfMaps_ThenDot(t *testing.T) {
+ // array.first.property
+ out := renderS5(t, `{{ products.first.title }}`, map[string]any{
+ "products": []map[string]any{
+ {"title": "Widget", "price": 10},
+ {"title": "Gadget", "price": 20},
+ },
+ })
+ require.Equal(t, "Widget", out)
+}
+
+func TestS5_Last_OnArrayOfMaps_ThenDot(t *testing.T) {
+ out := renderS5(t, `{{ products.last.price }}`, map[string]any{
+ "products": []map[string]any{
+ {"title": "Widget", "price": 10},
+ {"title": "Gadget", "price": 20},
+ },
+ })
+ require.Equal(t, "20", out)
+}
+
+func TestS5_First_OnSplitResult(t *testing.T) {
+ out := renderS5(t,
+ `{% assign words = "hello world foo" | split: " " %}{{ words.first }}`,
+ nil)
+ require.Equal(t, "hello", out)
+}
+
+func TestS5_Last_OnSplitResult(t *testing.T) {
+ out := renderS5(t,
+ `{% assign words = "hello world foo" | split: " " %}{{ words.last }}`,
+ nil)
+ require.Equal(t, "foo", out)
+}
+
+func TestS5_First_ThenFilter(t *testing.T) {
+ // array.first | upcase — property access then filter
+ out := renderS5(t, `{{ names.first | upcase }}`, map[string]any{
+ "names": []string{"alice", "bob"},
+ })
+ require.Equal(t, "ALICE", out)
+}
+
+func TestS5_Last_ThenFilter(t *testing.T) {
+ out := renderS5(t, `{{ names.last | upcase }}`, map[string]any{
+ "names": []string{"alice", "bob"},
+ })
+ require.Equal(t, "BOB", out)
+}
+
+func TestS5_Size_OnNilValue_ReturnsEmpty(t *testing.T) {
+ out := renderS5(t, `{{ v.size }}`, map[string]any{"v": nil})
+ require.Equal(t, "", out)
+}
+
+func TestS5_Size_OnBoolValue_ReturnsEmpty(t *testing.T) {
+ // booleans have no size
+ out := renderS5(t, `{{ v.size }}`, map[string]any{"v": true})
+ require.Equal(t, "", out)
+}
+
+func TestS5_Size_OnInteger_ReturnsEmpty(t *testing.T) {
+ // integers have no size property (unlike strings)
+ out := renderS5(t, `{{ v.size }}`, map[string]any{"v": 42})
+ require.Equal(t, "", out)
+}
+
+// ── Dot notation on diverse Go types ─────────────────────────────────────────
+
+func TestS5_DotNotation_OnMapYAMLStyleKeys(t *testing.T) {
+ // yaml-style keys with colons in the key name are not accessible via dot,
+ // but normal keys are
+ out := renderS5(t, `{{ config.host }}:{{ config.port }}`, map[string]any{
+ "config": map[string]any{"host": "localhost", "port": 8080},
+ })
+ require.Equal(t, "localhost:8080", out)
+}
+
+func TestS5_DotNotation_OnFalseValue_IsFalse(t *testing.T) {
+ // accessing a key whose value is `false` must render "false", not ""
+ out := renderS5(t, `{{ flags.active }}`, map[string]any{
+ "flags": map[string]any{"active": false},
+ })
+ require.Equal(t, "false", out)
+}
+
+func TestS5_DotNotation_OnIntValue_Renders(t *testing.T) {
+ out := renderS5(t, `{{ obj.count }}`, map[string]any{
+ "obj": map[string]any{"count": 7},
+ })
+ require.Equal(t, "7", out)
+}
+
+func TestS5_DotNotation_KeyShadowsBuiltin_Size(t *testing.T) {
+ // if map has a "size" key, it must beat the built-in .size shortcut
+ out := renderS5(t, `{{ m.size }}`, map[string]any{
+ "m": map[string]any{"size": "custom"},
+ })
+ require.Equal(t, "custom", out)
+}
+
+func TestS5_DotNotation_KeyShadowsBuiltin_First(t *testing.T) {
+ // if map has a "first" key → use it, not the array shortcut
+ out := renderS5(t, `{{ m.first }}`, map[string]any{
+ "m": map[string]any{"first": "overridden"},
+ })
+ require.Equal(t, "overridden", out)
+}
+
+// ── Bracket notation edge cases ───────────────────────────────────────────────
+
+func TestS5_BracketIndex_NegativeFromExpression(t *testing.T) {
+ // arr[0 - 1] — computed negative index via expression
+ out := renderS5(t, `{{ arr[n] }}`, map[string]any{
+ "arr": []string{"x", "y", "z"},
+ "n": -1,
+ })
+ require.Equal(t, "z", out)
+}
+
+func TestS5_BracketKey_EmptyStringKey(t *testing.T) {
+ // map[""] — empty string key is a valid map key
+ out := renderS5(t, `{{ m[""] }}`, map[string]any{
+ "m": map[string]any{"": "empty-key"},
+ })
+ require.Equal(t, "empty-key", out)
+}
+
+func TestS5_BracketKey_NumericStringKey(t *testing.T) {
+ // map["1"] — string key that looks like a number
+ out := renderS5(t, `{{ m["1"] }}`, map[string]any{
+ "m": map[string]any{"1": "string-one"},
+ })
+ require.Equal(t, "string-one", out)
+}
+
+// ── Dynamic lookup [key] — extra stress ──────────────────────────────────────
+
+func TestS5_DynamicLookup_TwoStepViaAssign(t *testing.T) {
+ // Two-step indirection via assign:
+ // pointer="level1", level1="level2" (a key name), level2="final value"
+ // [pointer] → looks up pointer → "level1", then context["level1"] = "level2"
+ // {% assign mid = [pointer] %} → mid = "level2"
+ // {{ [mid] }} → looks up mid → "level2", context["level2"] = "final value"
+ out := renderS5(t,
+ `{% assign mid = [pointer] %}{{ [mid] }}`,
+ map[string]any{
+ "pointer": "level1",
+ "level1": "level2",
+ "level2": "final value",
+ })
+ require.Equal(t, "final value", out)
+}
+
+func TestS5_DynamicLookup_InForCollection(t *testing.T) {
+ // Use dynamic lookup as the for-loop collection
+ out := renderS5(t,
+ `{% for item in [collection_key] %}{{ item }} {% endfor %}`,
+ map[string]any{
+ "collection_key": "fruits",
+ "fruits": []string{"apple", "banana"},
+ })
+ require.Equal(t, "apple banana ", out)
+}
+
+func TestS5_DynamicLookup_InAssignRHS(t *testing.T) {
+ // {% assign val = [key] %} — dynamic lookup on the right side of assign
+ out := renderS5(t,
+ `{% assign result = [key] %}{{ result | upcase }}`,
+ map[string]any{"key": "greeting", "greeting": "hello"})
+ require.Equal(t, "HELLO", out)
+}
+
+func TestS5_DynamicLookup_InCaptureBody(t *testing.T) {
+ out := renderS5(t,
+ `{% capture buf %}{{ [k] }}{% endcapture %}[{{ buf }}]`,
+ map[string]any{"k": "msg", "msg": "hi"})
+ require.Equal(t, "[hi]", out)
+}
+
+func TestS5_DynamicLookup_WithFilterOnResult(t *testing.T) {
+ out := renderS5(t, `{{ [k] | upcase }}`, map[string]any{
+ "k": "name", "name": "world",
+ })
+ require.Equal(t, "WORLD", out)
+}
+
+func TestS5_DynamicLookup_WithDotChainOnResult(t *testing.T) {
+ out := renderS5(t, `{{ [k].title }}`, map[string]any{
+ "k": "product",
+ "product": map[string]any{"title": "Widget"},
+ })
+ require.Equal(t, "Widget", out)
+}
+
+func TestS5_DynamicLookup_WithNegIndexOnResult(t *testing.T) {
+ out := renderS5(t, `{{ [k][-1] }}`, map[string]any{
+ "k": "arr",
+ "arr": []string{"a", "b", "c"},
+ })
+ require.Equal(t, "c", out)
+}
+
+// ── Dot-with-spaces stress ────────────────────────────────────────────────────
+
+func TestS5_DotWithSpaces_InForLoopCollection(t *testing.T) {
+ out := renderS5(t,
+ `{% for item in site . pages %}{{ item }} {% endfor %}`,
+ map[string]any{
+ "site": map[string]any{"pages": []string{"home", "about"}},
+ })
+ require.Equal(t, "home about ", out)
+}
+
+func TestS5_DotWithSpaces_InAssign(t *testing.T) {
+ out := renderS5(t,
+ `{% assign t = obj . title %}{{ t }}`,
+ map[string]any{"obj": map[string]any{"title": "My Title"}})
+ require.Equal(t, "My Title", out)
+}
+
+func TestS5_DotWithSpaces_VeryManySpaces(t *testing.T) {
+ // lots of spaces on both sides of the dot
+ out := renderS5(t, "{{ a . b }}", map[string]any{
+ "a": map[string]any{"b": "spaced"},
+ })
+ require.Equal(t, "spaced", out)
+}
+
+func TestS5_DotWithSpaces_MixedTabAndSpace(t *testing.T) {
+ out := renderS5(t, "{{ a\t .\t b }}", map[string]any{
+ "a": map[string]any{"b": "mixed-ws"},
+ })
+ require.Equal(t, "mixed-ws", out)
+}
+
+// ── StrictVariables: comprehensive ───────────────────────────────────────────
+
+func TestS5_StrictVariables_UndefinedRootVarErrors(t *testing.T) {
+ eng := liquid.NewEngine()
+ eng.StrictVariables()
+ _, err := eng.ParseAndRenderString(`{{ ghost }}`, nil)
+ require.Error(t, err)
+}
+
+func TestS5_StrictVariables_UndefinedPropertyOnExistingMapReturnsEmpty(t *testing.T) {
+ // StrictVariables only fires for undefined ROOT variables.
+ // A property missing on an existing map/struct just returns nil (no error).
+ eng := liquid.NewEngine()
+ eng.StrictVariables()
+ out, err := eng.ParseAndRenderString(`{{ obj.missing }}`, map[string]any{
+ "obj": map[string]any{"present": "yes"},
+ })
+ require.NoError(t, err)
+ require.Equal(t, "", out)
+}
+
+func TestS5_StrictVariables_DeepChainOnDefinedRootReturnsEmpty(t *testing.T) {
+ eng := liquid.NewEngine()
+ eng.StrictVariables()
+ out, err := eng.ParseAndRenderString(`{{ a.b.c.d }}`, map[string]any{
+ "a": map[string]any{"b": nil},
+ })
+ require.NoError(t, err)
+ require.Equal(t, "", out)
+}
+
+func TestS5_StrictVariables_NegIndexOnDefinedArrayReturnsEmpty(t *testing.T) {
+ eng := liquid.NewEngine()
+ eng.StrictVariables()
+ out, err := eng.ParseAndRenderString(`{{ arr[-9] }}`, map[string]any{
+ "arr": []string{"a"},
+ })
+ require.NoError(t, err)
+ require.Equal(t, "", out)
+}
+
+// ── ForLoop-based index access ────────────────────────────────────────────────
+
+func TestS5_ForLoopIndex_AccessArrayByForloopIndex0(t *testing.T) {
+ // {{ data[forloop.index0] }} — use loop counter as array index
+ out := renderS5(t,
+ `{% for _ in (1..3) %}{{ letters[forloop.index0] }}{% endfor %}`,
+ map[string]any{"letters": []string{"A", "B", "C"}})
+ require.Equal(t, "ABC", out)
+}
+
+func TestS5_ForLoopIndex_DescendingWithRindex(t *testing.T) {
+ // Use forloop.rindex (1-based distance from end) via assign + negative compute
+ // to verify rindex0 is accessible and usable in conditional logic.
+ out := renderS5(t,
+ `{% for _ in (1..3) %}{{ forloop.rindex0 }}{% endfor %}`,
+ map[string]any{})
+ // rindex0: 2,1,0
+ require.Equal(t, "210", out)
+}
+
+func TestS5_ForLoopIndex_AccessNestedProperties(t *testing.T) {
+ // In loop, access nested property of each item
+ out := renderS5(t,
+ `{% for p in products %}{{ p.name }}={{ p.price }} {% endfor %}`,
+ map[string]any{
+ "products": []map[string]any{
+ {"name": "A", "price": 10},
+ {"name": "B", "price": 20},
+ },
+ })
+ require.Equal(t, "A=10 B=20 ", out)
+}
+
+func TestS5_ForLoopOver_NegativeIndexResult(t *testing.T) {
+ // Iterate over arr[-1] when it is itself a slice
+ out := renderS5(t,
+ `{% for item in matrix[-1] %}{{ item }} {% endfor %}`,
+ map[string]any{
+ "matrix": [][]string{{"a", "b"}, {"c", "d"}},
+ })
+ require.Equal(t, "c d ", out)
+}
+
+// ── case/when with property access ───────────────────────────────────────────
+
+func TestS5_CaseWhen_PropertyAccess(t *testing.T) {
+ out := renderS5(t,
+ `{% case product.type %}{% when "shirt" %}shirt{% when "pants" %}pants{% else %}other{% endcase %}`,
+ map[string]any{"product": map[string]any{"type": "shirt"}})
+ require.Equal(t, "shirt", out)
+}
+
+func TestS5_CaseWhen_NegativeIndexResult(t *testing.T) {
+ out := renderS5(t,
+ `{% case arr[-1] %}{% when "z" %}last-z{% else %}other{% endcase %}`,
+ map[string]any{"arr": []string{"x", "y", "z"}})
+ require.Equal(t, "last-z", out)
+}
+
+// ── contains operator with nested access ────────────────────────────────────
+
+func TestS5_Contains_ArrayViaProperty(t *testing.T) {
+ out := renderS5(t,
+ `{% if user.roles contains "admin" %}admin{% else %}not-admin{% endif %}`,
+ map[string]any{"user": map[string]any{"roles": []string{"user", "admin"}}})
+ require.Equal(t, "admin", out)
+}
+
+func TestS5_Contains_StringViaProperty(t *testing.T) {
+ out := renderS5(t,
+ `{% if page.title contains "Go" %}yes{% else %}no{% endif %}`,
+ map[string]any{"page": map[string]any{"title": "Learning Go"}})
+ require.Equal(t, "yes", out)
+}
+
+// ── Assign from complex access ────────────────────────────────────────────────
+
+func TestS5_Assign_FromDotChain(t *testing.T) {
+ out := renderS5(t,
+ `{% assign title = page.meta.title %}{{ title }}`,
+ map[string]any{
+ "page": map[string]any{
+ "meta": map[string]any{"title": "My Page"},
+ },
+ })
+ require.Equal(t, "My Page", out)
+}
+
+func TestS5_Assign_FromNegativeIndex(t *testing.T) {
+ out := renderS5(t,
+ `{% assign last = arr[-1] %}{{ last | upcase }}`,
+ map[string]any{"arr": []string{"alpha", "beta", "gamma"}})
+ require.Equal(t, "GAMMA", out)
+}
+
+func TestS5_Assign_FromFirst(t *testing.T) {
+ out := renderS5(t,
+ `{% assign head = arr.first %}{{ head }}-{{ arr.size }}`,
+ map[string]any{"arr": []string{"one", "two", "three"}})
+ require.Equal(t, "one-3", out)
+}
+
+// ── Filter chain combined with access ────────────────────────────────────────
+
+func TestS5_FilterChain_MapThenIndex(t *testing.T) {
+ // {{ products | map: "price" | first }} — map-filter then .first
+ out := renderS5(t,
+ `{{ products | map: "price" | first }}`,
+ map[string]any{
+ "products": []map[string]any{
+ {"price": 10}, {"price": 20},
+ },
+ })
+ require.Equal(t, "10", out)
+}
+
+func TestS5_FilterChain_SortThenFirst(t *testing.T) {
+ out := renderS5(t,
+ `{{ nums | sort | first }}`,
+ map[string]any{"nums": []int{5, 2, 8, 1}})
+ require.Equal(t, "1", out)
+}
+
+func TestS5_FilterChain_SortThenLast(t *testing.T) {
+ out := renderS5(t,
+ `{{ nums | sort | last }}`,
+ map[string]any{"nums": []int{5, 2, 8, 1}})
+ require.Equal(t, "8", out)
+}
+
+func TestS5_FilterChain_ReverseThenNegIndex(t *testing.T) {
+ // After reverse, arr[-1] is now what was arr[0]
+ out := renderS5(t,
+ `{% assign rev = arr | reverse %}{{ rev[-1] }}`,
+ map[string]any{"arr": []string{"a", "b", "c"}})
+ require.Equal(t, "a", out)
+}
+
+func TestS5_FilterChain_SplitThenSize(t *testing.T) {
+ out := renderS5(t,
+ `{% assign parts = "a,b,c,d" | split: "," %}{{ parts.size }}`,
+ nil)
+ require.Equal(t, "4", out)
+}
+
+// ── Tablerow with access ──────────────────────────────────────────────────────
+
+func TestS5_Tablerow_PropertyAccess(t *testing.T) {
+ out := renderS5(t,
+ `{% tablerow item in collection.items cols:2 %}{{ item.name }}{% endtablerow %}`,
+ map[string]any{
+ "collection": map[string]any{
+ "items": []map[string]any{
+ {"name": "A"}, {"name": "B"}, {"name": "C"}, {"name": "D"},
+ },
+ },
+ })
+ require.Contains(t, out, "A")
+ require.Contains(t, out, "D")
+}
+
+// ── Complex real-world template: Shopify product page simulation ─────────────
+
+func TestS5_RealWorld_ProductPage(t *testing.T) {
+ tpl := `
+Title: {{ product.title }}
+Price: ${{ product.variants[0].price }}
+Last variant: {{ product.variants[-1].title }}
+Size: {{ product.variants.size }}
+Tags: {{ product.tags | join: ", " }}
+Featured: {{ product.meta.featured }}
+`
+ out := renderS5(t, tpl, map[string]any{
+ "product": map[string]any{
+ "title": "Super Shirt",
+ "variants": []map[string]any{
+ {"title": "Small", "price": 29},
+ {"title": "Medium", "price": 32},
+ {"title": "Large", "price": 35},
+ },
+ "tags": []string{"sale", "summer"},
+ "meta": map[string]any{"featured": true},
+ },
+ })
+ require.Contains(t, out, "Title: Super Shirt")
+ require.Contains(t, out, "Price: $29")
+ require.Contains(t, out, "Last variant: Large")
+ require.Contains(t, out, "Size: 3")
+ require.Contains(t, out, "Tags: sale, summer")
+ require.Contains(t, out, "Featured: true")
+}
+
+func TestS5_RealWorld_NavigationMenu(t *testing.T) {
+ tpl := `{% for link in linklists.main_menu.links %}{{ link.title }}{% unless forloop.last %} | {% endunless %}{% endfor %}`
+
+ out := renderS5(t, tpl, map[string]any{
+ "linklists": map[string]any{
+ "main_menu": map[string]any{
+ "links": []map[string]any{
+ {"title": "Home"},
+ {"title": "About"},
+ {"title": "Contact"},
+ },
+ },
+ },
+ })
+ require.Equal(t, "Home | About | Contact", out)
+}
+
+func TestS5_RealWorld_ConditionalAccessNested(t *testing.T) {
+ tpl := `{% if customer.address.country == "US" %}ship-domestic{% else %}ship-international{% endif %}`
+
+ out := renderS5(t, tpl, map[string]any{
+ "customer": map[string]any{
+ "address": map[string]any{"country": "US"},
+ },
+ })
+ require.Equal(t, "ship-domestic", out)
+}
+
+func TestS5_RealWorld_DynamicSectionRendering(t *testing.T) {
+ // Dynamic lookup used to switch between different section keys
+ tpl := `{% for section in page.sections %}{{ [section].heading }}: {{ [section].body }} {% endfor %}`
+
+ out := renderS5(t, tpl, map[string]any{
+ "page": map[string]any{
+ "sections": []string{"hero", "cta"},
+ },
+ "hero": map[string]any{"heading": "Welcome", "body": "intro text"},
+ "cta": map[string]any{"heading": "Buy Now", "body": "limited offer"},
+ })
+ require.Equal(t, "Welcome: intro text Buy Now: limited offer ", out)
+}
+
+// ── Capture + access combination ─────────────────────────────────────────────
+
+func TestS5_Capture_UsesPropertyAccess(t *testing.T) {
+ out := renderS5(t,
+ `{% capture greeting %}Hello, {{ user.name }}!{% endcapture %}{{ greeting }}`,
+ map[string]any{"user": map[string]any{"name": "Alice"}})
+ require.Equal(t, "Hello, Alice!", out)
+}
+
+func TestS5_Capture_UsesNegativeIndex(t *testing.T) {
+ out := renderS5(t,
+ `{% capture last %}{{ items[-1] }}{% endcapture %}[{{ last }}]`,
+ map[string]any{"items": []string{"one", "two", "three"}})
+ require.Equal(t, "[three]", out)
+}
+
+// ── Stability: runs must be idempotent ────────────────────────────────────────
+
+func TestS5_Idempotent_SameEngineMultipleRenders(t *testing.T) {
+ // Same engine, same template, multiple renders must produce identical output.
+ eng := liquid.NewEngine()
+ tpl, err := eng.ParseString(`{{ obj.a[-1] }}`)
+ require.NoError(t, err)
+
+ binds := map[string]any{
+ "obj": map[string]any{"a": []string{"x", "y", "z"}},
+ }
+
+ for i := range 5 {
+ out, err := tpl.RenderString(binds)
+ require.NoError(t, err)
+ require.Equal(t, "z", out, "render %d should be 'z'", i+1)
+ }
+}
+
+func TestS5_Idempotent_NegIndexAfterFilterChain(t *testing.T) {
+ eng := liquid.NewEngine()
+ tpl, err := eng.ParseString(`{% assign s = "a,b,c,d" | split: "," %}{{ s[-1] }},{{ s[-2] }}`)
+ require.NoError(t, err)
+
+ for range 3 {
+ out, err := tpl.RenderString(nil)
+ require.NoError(t, err)
+ require.Equal(t, "d,c", out)
+ }
+}
diff --git a/s8_engine_config_e2e_test.go b/s8_engine_config_e2e_test.go
new file mode 100644
index 00000000..a795c9b7
--- /dev/null
+++ b/s8_engine_config_e2e_test.go
@@ -0,0 +1,1408 @@
+package liquid_test
+
+// s8_engine_config_e2e_test.go — Intensive E2E tests for Section 8: Configuration / Engine
+//
+// Coverage matrix:
+// A. StrictVariables — engine-level and per-render, exact error messages, defined vars ok
+// B. LaxFilters — engine-level and per-render, passthrough behavior, filter chaining
+// C. LaxTags — unknown as noop, known tags work, LaxTags does not affect filter strictness
+// D. Delims — custom tag/output delimiters, old delims become literal, empty string restores
+// E. RegisterFilter — custom filter, arg passing, chaining, override standard
+// F. RegisterTag — context access, state, multi-render isolation
+// G. RegisterBlock — InnerString, conditional content, nested manipulation
+// H. UnregisterTag — hot-replace pattern, idempotent removal
+// I. RegisterTemplateStore — in-memory store, include dispatch, multiple files
+// J. SetGlobals + WithGlobals — render hierarchy (bindings > per-render > engine), persistence
+// K. SetGlobalFilter + WithGlobalFilter — all outputs transformed, per-render override, combined
+// L. SetExceptionHandler + WithErrorHandler — recovery, collection, per-render overrides engine
+// M. WithSizeLimit — loop-generated content, UTF-8 bytes, zero = unlimited, per-render isolation
+// N. WithContext — cancellation, timeout in loop, background context passes through
+// O. EnableCache — cache hit returns same pointer, invalidation, concurrent safety
+// P. EnableJekyllExtensions — dot assign in real templates, standard assign still works
+// Q. SetAutoEscapeReplacer — HTML escaping in output, raw filter bypasses, interaction
+// R. NewBasicEngine — no standard tags/filters, custom registration works
+// S. Combinations — multiple render options together, realistic template scenarios
+// T. Real-world — blog layout, error recovery report, custom-auth tag pipeline
+//
+// Every test is self-contained: it creates its own engine and store.
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/osteele/liquid"
+ "github.com/osteele/liquid/render"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+func s8eng() *liquid.Engine { return liquid.NewEngine() }
+
+func s8render(t *testing.T, eng *liquid.Engine, tpl string, binds map[string]any, opts ...liquid.RenderOption) string {
+ t.Helper()
+ out, err := eng.ParseAndRenderString(tpl, binds, opts...)
+ require.NoError(t, err, "template: %q", tpl)
+ return out
+}
+
+func s8renderErr(t *testing.T, eng *liquid.Engine, tpl string, binds map[string]any, opts ...liquid.RenderOption) (string, error) {
+ t.Helper()
+ return eng.ParseAndRenderString(tpl, binds, opts...)
+}
+
+// mapStore is an in-memory TemplateStore for testing RegisterTemplateStore.
+type mapStore struct{ files map[string]string }
+
+func (s *mapStore) ReadTemplate(name string) ([]byte, error) {
+ if src, ok := s.files[name]; ok {
+ return []byte(src), nil
+ }
+ return nil, fmt.Errorf("template %q not found", name)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// A. StrictVariables
+// ═════════════════════════════════════════════════════════════════════════════
+
+// A1 — engine-level strict: undefined variable is an error
+func TestS8_StrictVariables_Engine_ErrorOnUndefined(t *testing.T) {
+ eng := s8eng()
+ eng.StrictVariables()
+ _, err := s8renderErr(t, eng, `{{ undefined }}`, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "undefined")
+}
+
+// A2 — engine-level strict: error message includes the variable name
+func TestS8_StrictVariables_Engine_ErrorMessageContainsName(t *testing.T) {
+ eng := s8eng()
+ eng.StrictVariables()
+ _, err := s8renderErr(t, eng, `{{ my_custom_var }}`, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "my_custom_var")
+}
+
+// A3 — engine-level strict: defined variables render correctly
+func TestS8_StrictVariables_Engine_DefinedVarWorks(t *testing.T) {
+ eng := s8eng()
+ eng.StrictVariables()
+ out := s8render(t, eng, `{{ x }}`, map[string]any{"x": "hello"})
+ require.Equal(t, "hello", out)
+}
+
+// A4 — engine-level strict: intermediate variable in complex expression
+func TestS8_StrictVariables_Engine_ObjectPropertyStillResolves(t *testing.T) {
+ eng := s8eng()
+ eng.StrictVariables()
+ out := s8render(t, eng, `{{ user.name }}`, map[string]any{"user": map[string]any{"name": "Alice"}})
+ require.Equal(t, "Alice", out)
+}
+
+// A5 — per-render strict: overrides engine default (lax)
+func TestS8_StrictVariables_PerRender_OverridesEngineDefault(t *testing.T) {
+ eng := s8eng() // default: lax
+ _, err := s8renderErr(t, eng, `{{ missing }}`, nil, liquid.WithStrictVariables())
+ require.Error(t, err)
+}
+
+// A6 — per-render strict does not persist to next call
+func TestS8_StrictVariables_PerRender_DoesNotPersist(t *testing.T) {
+ eng := s8eng()
+ // Call 1: strict → error
+ _, err := s8renderErr(t, eng, `{{ missing }}`, nil, liquid.WithStrictVariables())
+ require.Error(t, err)
+ // Call 2: no option → lax, renders empty
+ out, err2 := s8renderErr(t, eng, `{{ missing }}`, nil)
+ require.NoError(t, err2)
+ require.Equal(t, "", out)
+}
+
+// A7 — strict: assign-defined variable is not treated as undefined
+func TestS8_StrictVariables_AssignedVarIsNotUndefined(t *testing.T) {
+ eng := s8eng()
+ eng.StrictVariables()
+ out := s8render(t, eng, `{% assign x = "world" %}{{ x }}`, nil)
+ require.Equal(t, "world", out)
+}
+
+// A8 — strict: for-loop variable is not undefined
+func TestS8_StrictVariables_ForLoopVarIsNotUndefined(t *testing.T) {
+ eng := s8eng()
+ eng.StrictVariables()
+ out := s8render(t, eng, `{% for i in (1..3) %}{{ i }}{% endfor %}`, nil)
+ require.Equal(t, "123", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// B. LaxFilters
+// ═════════════════════════════════════════════════════════════════════════════
+
+// B1 — engine-level LaxFilters: undefined filter passes value through
+func TestS8_LaxFilters_Engine_PassesThrough(t *testing.T) {
+ eng := s8eng()
+ eng.LaxFilters()
+ out := s8render(t, eng, `{{ "hello" | no_such_filter }}`, nil)
+ require.Equal(t, "hello", out)
+}
+
+// B2 — engine-level LaxFilters: defined filters still work
+func TestS8_LaxFilters_Engine_DefinedFilterWorks(t *testing.T) {
+ eng := s8eng()
+ eng.LaxFilters()
+ out := s8render(t, eng, `{{ "hello" | upcase }}`, nil)
+ require.Equal(t, "HELLO", out)
+}
+
+// B3 — engine-level LaxFilters: unknown filter in a chain — value passes through to next
+func TestS8_LaxFilters_Engine_UnknownInChainPassesThrough(t *testing.T) {
+ eng := s8eng()
+ eng.LaxFilters()
+ // unknown_filter passes value → upcase applies on the passed-through value
+ out := s8render(t, eng, `{{ "hello" | unknown_filter | upcase }}`, nil)
+ require.Equal(t, "HELLO", out)
+}
+
+// B4 — default (strict) mode: undefined filter causes error
+func TestS8_LaxFilters_Default_StrictErrors(t *testing.T) {
+ eng := s8eng()
+ _, err := s8renderErr(t, eng, `{{ "hello" | no_such_filter }}`, nil)
+ require.Error(t, err)
+}
+
+// B5 — per-render WithLaxFilters: overrides default strict
+func TestS8_LaxFilters_PerRender_OverridesDefault(t *testing.T) {
+ eng := s8eng()
+ out, err := s8renderErr(t, eng, `{{ "hello" | ghost_filter }}`, nil, liquid.WithLaxFilters())
+ require.NoError(t, err)
+ require.Equal(t, "hello", out)
+}
+
+// B6 — per-render WithLaxFilters: does not persist to next call
+func TestS8_LaxFilters_PerRender_DoesNotPersist(t *testing.T) {
+ eng := s8eng()
+ // Call 1: lax → no error
+ out, _ := s8renderErr(t, eng, `{{ "x" | ghost_filter }}`, nil, liquid.WithLaxFilters())
+ require.Equal(t, "x", out)
+ // Call 2: default strict → error
+ _, err := s8renderErr(t, eng, `{{ "x" | ghost_filter }}`, nil)
+ require.Error(t, err)
+}
+
+// B7 — LaxFilters + LaxTags: both can be enabled together
+func TestS8_LaxFilters_AndLaxTags_Together(t *testing.T) {
+ eng := s8eng()
+ eng.LaxFilters()
+ eng.LaxTags()
+ out := s8render(t, eng, `{% ghost_tag %}{{ "hello" | ghost_filter }}`, nil)
+ require.Equal(t, "hello", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// C. LaxTags
+// ═════════════════════════════════════════════════════════════════════════════
+
+// C1 — unknown tag becomes noop: text around it is preserved
+func TestS8_LaxTags_UnknownTagIsNoop(t *testing.T) {
+ eng := s8eng()
+ eng.LaxTags()
+ out := s8render(t, eng, `before{% ghost_tag arg1 arg2 %}after`, nil)
+ require.Equal(t, "beforeafter", out)
+}
+
+// C2 — default: unknown tag is a parse error
+func TestS8_LaxTags_Default_UnknownTagIsError(t *testing.T) {
+ eng := s8eng()
+ _, err := eng.ParseString(`{% ghost_tag %}`)
+ require.Error(t, err)
+}
+
+// C3 — LaxTags: known standard tags still work correctly
+func TestS8_LaxTags_KnownTagsStillWork(t *testing.T) {
+ eng := s8eng()
+ eng.LaxTags()
+ out := s8render(t, eng, `{% if x %}yes{% else %}no{% endif %}`, map[string]any{"x": true})
+ require.Equal(t, "yes", out)
+}
+
+// C4 — LaxTags: multiple unknown tags all silently ignored
+func TestS8_LaxTags_MultipleUnknownTagsAllIgnored(t *testing.T) {
+ eng := s8eng()
+ eng.LaxTags()
+ out := s8render(t, eng,
+ `{% foo %}{{ a }}{% bar baz %}{{ b }}{% qux 1 2 3 %}`,
+ map[string]any{"a": "A", "b": "B"},
+ )
+ require.Equal(t, "AB", out)
+}
+
+// C5 — LaxTags does NOT make filters lax; undefined filter still errors
+func TestS8_LaxTags_DoesNotImplyLaxFilters(t *testing.T) {
+ eng := s8eng()
+ eng.LaxTags()
+ _, err := s8renderErr(t, eng, `{{ "x" | unknown_filter }}`, nil)
+ require.Error(t, err)
+}
+
+// C6 — LaxTags: unknown tag adjacent to whitespace trim marker
+func TestS8_LaxTags_UnknownTag_WithTrimMarker_IsNoop(t *testing.T) {
+ eng := s8eng()
+ eng.LaxTags()
+ out := s8render(t, eng, `a {%- ghost_tag -%} b`, nil)
+ // With trim markers the whitespace around the noop tag is consumed
+ require.Equal(t, "ab", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// D. Delims
+// ═════════════════════════════════════════════════════════════════════════════
+
+// D1 — custom tag delimiters: template uses new delims correctly
+func TestS8_Delims_CustomTagDelims(t *testing.T) {
+ eng := s8eng()
+ // Delims(objectLeft, objectRight, tagLeft, tagRight)
+ eng.Delims("", "", "{!", "!}")
+ out := s8render(t, eng, `{! if x !}yes{! endif !}`, map[string]any{"x": true})
+ require.Equal(t, "yes", out)
+}
+
+// D2 — custom output delimiters: template uses new delims correctly
+func TestS8_Delims_CustomOutputDelims(t *testing.T) {
+ eng := s8eng()
+ // Delims(objectLeft, objectRight, tagLeft, tagRight)
+ eng.Delims("[[", "]]", "", "")
+ out := s8render(t, eng, `Hello [[ name ]]!`, map[string]any{"name": "World"})
+ require.Equal(t, "Hello World!", out)
+}
+
+// D3 — both custom: old delimiters become literal text
+func TestS8_Delims_OldDelimsBecomeLiteral(t *testing.T) {
+ eng := s8eng()
+ // Output = [[ ]], Tag = {! !}
+ eng.Delims("[[", "]]", "{!", "!}")
+ out := s8render(t, eng, `{{ name }} and [[ name ]]`, map[string]any{"name": "X"})
+ // {{ name }} is literal text; [[ name ]] is the active output delim
+ require.Equal(t, "{{ name }} and X", out)
+}
+
+// D4 — empty strings restore defaults
+func TestS8_Delims_EmptyRestoresDefaults(t *testing.T) {
+ eng := s8eng()
+ eng.Delims("", "", "", "")
+ out := s8render(t, eng, `{{ x }}`, map[string]any{"x": "ok"})
+ require.Equal(t, "ok", out)
+}
+
+// D5 — custom delims: for-loop with custom tag and output delimiters together
+func TestS8_Delims_ForLoopWithCustomTagDelims(t *testing.T) {
+ eng := s8eng()
+ // Output = <% %>, Tag = <$ $>
+ eng.Delims("<%", "%>", "<$", "$>")
+ out := s8render(t, eng,
+ `<$ for i in (1..3) $><% i %><$ endfor $>`,
+ nil,
+ )
+ require.Equal(t, "123", out)
+}
+
+// D6 — old standard delims become literal after Delims() is called with custom values
+func TestS8_Delims_StandardDelimsBecomeLiteral(t *testing.T) {
+ eng := s8eng()
+ // Set custom delims: output = [[ ]], tag = [% %]
+ eng.Delims("[[", "]]", "[%", "%]")
+ // Standard {{ }} and {% %} are now literal text
+ out := s8render(t, eng, `{{ x }} [[ x ]]`, map[string]any{"x": "ok"})
+ require.Equal(t, "{{ x }} ok", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// E. RegisterFilter
+// ═════════════════════════════════════════════════════════════════════════════
+
+// E1 — custom filter: basic transformation
+func TestS8_RegisterFilter_BasicTransform(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("shout", func(s string) string {
+ return strings.ToUpper(s) + "!!!"
+ })
+ out := s8render(t, eng, `{{ "hello" | shout }}`, nil)
+ require.Equal(t, "HELLO!!!", out)
+}
+
+// E2 — custom filter with argument
+func TestS8_RegisterFilter_WithArg(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("repeat", func(s string, n int) string {
+ return strings.Repeat(s, n)
+ })
+ out := s8render(t, eng, `{{ "ab" | repeat: 3 }}`, nil)
+ require.Equal(t, "ababab", out)
+}
+
+// E3 — custom filter chained with standard filter
+func TestS8_RegisterFilter_ChainedWithStandard(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("exclaim", func(s string) string { return s + "!" })
+ out := s8render(t, eng, `{{ "hello" | exclaim | upcase }}`, nil)
+ require.Equal(t, "HELLO!", out)
+}
+
+// E4 — custom filter returning error
+func TestS8_RegisterFilter_ReturnsError(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("fail_always", func(s string) (string, error) {
+ return "", fmt.Errorf("filter failed: %s", s)
+ })
+ _, err := s8renderErr(t, eng, `{{ "oops" | fail_always }}`, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "filter failed")
+}
+
+// E5 — custom filter can shadow a standard filter
+func TestS8_RegisterFilter_ShadowsStandard(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("upcase", func(s string) string { return "CUSTOM:" + s })
+ out := s8render(t, eng, `{{ "hi" | upcase }}`, nil)
+ require.Equal(t, "CUSTOM:hi", out)
+}
+
+// E6 — custom filter on numeric input
+func TestS8_RegisterFilter_NumericInput(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("square", func(n int) int { return n * n })
+ out := s8render(t, eng, `{{ 7 | square }}`, nil)
+ require.Equal(t, "49", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// F. RegisterTag
+// ═════════════════════════════════════════════════════════════════════════════
+
+// F1 — custom tag: reads TagArgs and renders output
+func TestS8_RegisterTag_ReadsArgsAndRenders(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("greet", func(ctx render.Context) (string, error) {
+ return "Hello, " + ctx.TagArgs() + "!", nil
+ })
+ out := s8render(t, eng, `{% greet World %}`, nil)
+ require.Equal(t, "Hello, World!", out)
+}
+
+// F2 — custom tag: reads from context variables
+func TestS8_RegisterTag_ReadsContextVariable(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("greet_user", func(ctx render.Context) (string, error) {
+ v := ctx.Get("username")
+ name, _ := v.(string)
+ return "Hi " + name, nil
+ })
+ out := s8render(t, eng, `{% greet_user %}`, map[string]any{"username": "Alice"})
+ require.Equal(t, "Hi Alice", out)
+}
+
+// F3 — custom tag: output is independent across multiple renders
+func TestS8_RegisterTag_OutputIsIsolatedPerRender(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("ping", func(ctx render.Context) (string, error) {
+ return "pong", nil
+ })
+ for range 3 {
+ out := s8render(t, eng, `{% ping %}`, nil)
+ require.Equal(t, "pong", out)
+ }
+}
+
+// F4 — custom tag: multiple custom tags coexist
+func TestS8_RegisterTag_MultipleCustomTagsCoexist(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("tagA", func(_ render.Context) (string, error) { return "A", nil })
+ eng.RegisterTag("tagB", func(_ render.Context) (string, error) { return "B", nil })
+ out := s8render(t, eng, `{% tagA %}{% tagB %}{% tagA %}`, nil)
+ require.Equal(t, "ABA", out)
+}
+
+// F5 — custom tag: can call EvaluateString for expressions
+func TestS8_RegisterTag_EvaluatesExpression(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("eval_tag", func(ctx render.Context) (string, error) {
+ v, err := ctx.EvaluateString(ctx.TagArgs())
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%v", v), nil
+ })
+ out := s8render(t, eng, `{% eval_tag x | upcase %}`, map[string]any{"x": "hello"})
+ require.Equal(t, "HELLO", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// G. RegisterBlock
+// ═════════════════════════════════════════════════════════════════════════════
+
+// G1 — custom block: wraps InnerString in custom markup
+func TestS8_RegisterBlock_WrapsInnerContent(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterBlock("wrap", func(ctx render.Context) (string, error) {
+ inner, err := ctx.InnerString()
+ if err != nil {
+ return "", err
+ }
+ return "[" + strings.TrimSpace(inner) + "]", nil
+ })
+ out := s8render(t, eng, `{% wrap %} hello {% endwrap %}`, nil)
+ require.Equal(t, "[hello]", out)
+}
+
+// G2 — custom block: inner content has access to outer variables
+func TestS8_RegisterBlock_InnerAccessesOuterVars(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterBlock("uppercase_block", func(ctx render.Context) (string, error) {
+ inner, err := ctx.InnerString()
+ if err != nil {
+ return "", err
+ }
+ return strings.ToUpper(inner), nil
+ })
+ out := s8render(t, eng, `{% uppercase_block %}{{ name }}{% enduppercase_block %}`, map[string]any{"name": "alice"})
+ require.Equal(t, "ALICE", out)
+}
+
+// G3 — custom block: TagArgs available in block handler
+func TestS8_RegisterBlock_TagArgsAvailable(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterBlock("labeled", func(ctx render.Context) (string, error) {
+ inner, err := ctx.InnerString()
+ if err != nil {
+ return "", err
+ }
+ return ctx.TagArgs() + ": " + strings.TrimSpace(inner), nil
+ })
+ out := s8render(t, eng, `{% labeled warning %}danger!{% endlabeled %}`, nil)
+ require.Equal(t, "warning: danger!", out)
+}
+
+// G4 — custom block: renders empty inner content gracefully
+func TestS8_RegisterBlock_EmptyInnerContent(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterBlock("maybe", func(ctx render.Context) (string, error) {
+ inner, err := ctx.InnerString()
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(inner) == "" {
+ return "(empty)", nil
+ }
+ return inner, nil
+ })
+ out := s8render(t, eng, `{% maybe %}{% endmaybe %}`, nil)
+ require.Equal(t, "(empty)", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// H. UnregisterTag
+// ═════════════════════════════════════════════════════════════════════════════
+
+// H1 — UnregisterTag: removes a previously registered custom tag
+func TestS8_UnregisterTag_RemovesCustomTag(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("my_tag", func(_ render.Context) (string, error) { return "hi", nil })
+ eng.UnregisterTag("my_tag")
+ // After removal, the tag should cause a parse error (strict mode)
+ _, err := eng.ParseString(`{% my_tag %}`)
+ require.Error(t, err)
+}
+
+// H2 — UnregisterTag: idempotent — calling on unknown tag does not panic
+func TestS8_UnregisterTag_IdempotentOnUnknown(t *testing.T) {
+ eng := s8eng()
+ require.NotPanics(t, func() { eng.UnregisterTag("nonexistent_tag") })
+}
+
+// H3 — UnregisterTag: can remove then re-register with new behavior
+func TestS8_UnregisterTag_ReRegisterWithNewBehavior(t *testing.T) {
+ eng1 := s8eng()
+ eng1.RegisterTag("v_tag", func(_ render.Context) (string, error) { return "v1", nil })
+ out1 := s8render(t, eng1, `{% v_tag %}`, nil)
+ require.Equal(t, "v1", out1)
+
+ // New engine: different behavior
+ eng2 := s8eng()
+ eng2.RegisterTag("v_tag", func(_ render.Context) (string, error) { return "v2", nil })
+ out2 := s8render(t, eng2, `{% v_tag %}`, nil)
+ require.Equal(t, "v2", out2)
+}
+
+// H4 — UnregisterTag: standard tags can be unregistered (LaxTags not required)
+func TestS8_UnregisterTag_CanRemoveStandardTag(t *testing.T) {
+ eng := s8eng()
+ eng.UnregisterTag("assign")
+ eng.LaxTags() // to handle the now-unknown assign tag as noop
+ // assign is now a noop; variable stays undefined
+ out := s8render(t, eng, `{% assign x = "hello" %}{{ x }}`, nil)
+ require.Equal(t, "", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// I. RegisterTemplateStore
+// ═════════════════════════════════════════════════════════════════════════════
+
+// I1 — in-memory store: include resolves from store
+func TestS8_RegisterTemplateStore_IncludeFromStore(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{
+ "greeting.html": "Hello, {{ name }}!",
+ }})
+ out := s8render(t, eng, `{% include "greeting.html" %}`, map[string]any{"name": "World"})
+ require.Equal(t, "Hello, World!", out)
+}
+
+// I2 — store: unknown file causes error
+func TestS8_RegisterTemplateStore_UnknownFileErrors(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{}})
+ _, err := s8renderErr(t, eng, `{% include "missing.html" %}`, nil)
+ require.Error(t, err)
+}
+
+// I3 — store: multiple files, includes work for each
+func TestS8_RegisterTemplateStore_MultipleFilesWork(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{
+ "header.html": "",
+ "footer.html": "",
+ }})
+ out := s8render(t, eng,
+ `{% include "header.html" %}{% include "footer.html" %}`,
+ map[string]any{"title": "Home", "year": 2025},
+ )
+ require.Equal(t, "", out)
+}
+
+// I4 — store: included template inherits calling context variables
+func TestS8_RegisterTemplateStore_IncludedTemplateInheritsContext(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{
+ "part.html": "{{ shared_var }}",
+ }})
+ out := s8render(t, eng, `{% include "part.html" %}`, map[string]any{"shared_var": "shared!"})
+ require.Equal(t, "shared!", out)
+}
+
+// I5 — store: render tag uses isolated scope (render tag, not include)
+func TestS8_RegisterTemplateStore_RenderTagUsesIsolatedScope(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{
+ "isolated.html": "{{ secret }}",
+ }})
+ // render tag does NOT inherit parent scope — secret should be empty
+ out := s8render(t, eng, `{% render "isolated.html" %}`, map[string]any{"secret": "hidden"})
+ require.Equal(t, "", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// J. SetGlobals + WithGlobals
+// ═════════════════════════════════════════════════════════════════════════════
+
+// J1 — engine globals: accessible in every render without passing bindings
+func TestS8_SetGlobals_AccessibleInEveryRender(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"site": "Acme Corp", "version": 3})
+ out := s8render(t, eng, `{{ site }} v{{ version }}`, nil)
+ require.Equal(t, "Acme Corp v3", out)
+}
+
+// J2 — engine globals: persist across multiple renders
+func TestS8_SetGlobals_PersistAcrossRenders(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"env": "production"})
+ for i := range 5 {
+ out := s8render(t, eng, `{{ env }}`, nil)
+ require.Equal(t, "production", out, "render %d", i)
+ }
+}
+
+// J3 — binding overrides engine global when same key
+func TestS8_SetGlobals_BindingOverridesGlobal(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"color": "blue"})
+ out := s8render(t, eng, `{{ color }}`, map[string]any{"color": "red"})
+ require.Equal(t, "red", out)
+}
+
+// J4 — per-render WithGlobals: key present only for that call
+func TestS8_WithGlobals_PerRender_NotPersistent(t *testing.T) {
+ eng := s8eng()
+ out1 := s8render(t, eng, `{{ x }}`, nil, liquid.WithGlobals(map[string]any{"x": "transient"}))
+ require.Equal(t, "transient", out1)
+ out2 := s8render(t, eng, `{{ x }}`, nil)
+ require.Equal(t, "", out2)
+}
+
+// J5 — per-render WithGlobals merges with engine globals
+func TestS8_WithGlobals_MergesWithEngineGlobals(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"a": "A"})
+ out := s8render(t, eng, `{{ a }}-{{ b }}`, nil, liquid.WithGlobals(map[string]any{"b": "B"}))
+ require.Equal(t, "A-B", out)
+}
+
+// J6 — per-render WithGlobals overrides engine globals (same key)
+func TestS8_WithGlobals_OverridesEngineGlobals(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"env": "production"})
+ out := s8render(t, eng, `{{ env }}`, nil, liquid.WithGlobals(map[string]any{"env": "staging"}))
+ require.Equal(t, "staging", out)
+}
+
+// J7 — hierarchy is bindings > per-render globals > engine globals
+func TestS8_Globals_FullHierarchy(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"v": "engine"})
+ // per-render overrides engine; binding overrides per-render
+ out := s8render(t, eng, `{{ v }}`, map[string]any{"v": "binding"},
+ liquid.WithGlobals(map[string]any{"v": "per-render"}))
+ require.Equal(t, "binding", out)
+}
+
+// J8 — engine globals are visible in {% render %} isolated sub-contexts
+func TestS8_SetGlobals_VisibleInRenderIsolated(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{
+ "sub.html": "{{ site }}",
+ }})
+ eng.SetGlobals(map[string]any{"site": "MyBlog"})
+ out := s8render(t, eng, `{% render "sub.html" %}`, nil)
+ require.Equal(t, "MyBlog", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// K. SetGlobalFilter + WithGlobalFilter
+// ═════════════════════════════════════════════════════════════════════════════
+
+// K1 — engine global filter: transforms every {{ }} output
+func TestS8_SetGlobalFilter_TransformsAllOutputs(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ if s, ok := v.(string); ok {
+ return "<<" + s + ">>", nil
+ }
+ return v, nil
+ })
+ out := s8render(t, eng, `{{ a }} {{ b }}`, map[string]any{"a": "x", "b": "y"})
+ require.Equal(t, "<> <>", out)
+}
+
+// K2 — engine global filter: does not mutate literal text nodes
+func TestS8_SetGlobalFilter_DoesNotAffectLiteralText(t *testing.T) {
+ eng := s8eng()
+ callCount := 0
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ callCount++
+ return v, nil
+ })
+ out := s8render(t, eng, `literal text {{ x }}`, map[string]any{"x": "val"})
+ require.Equal(t, "literal text val", out)
+ require.Equal(t, 1, callCount, "filter called once (for the one {{ }} node)")
+}
+
+// K3 — engine global filter: error propagates to render error
+func TestS8_SetGlobalFilter_ErrorPropagates(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ return nil, fmt.Errorf("global filter exploded")
+ })
+ _, err := s8renderErr(t, eng, `{{ x }}`, map[string]any{"x": "val"})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "global filter exploded")
+}
+
+// K4 — per-render WithGlobalFilter: overrides engine-level filter
+func TestS8_WithGlobalFilter_OverridesEngineFilter(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ s, _ := v.(string)
+ return "[engine]" + s, nil
+ })
+ out, _ := s8renderErr(t, eng, `{{ x }}`, map[string]any{"x": "val"},
+ liquid.WithGlobalFilter(func(v any) (any, error) {
+ s, _ := v.(string)
+ return "[per-render]" + s, nil
+ }),
+ )
+ require.Equal(t, "[per-render]val", out)
+}
+
+// K5 — per-render WithGlobalFilter: does not persist across renders
+func TestS8_WithGlobalFilter_DoesNotPersist(t *testing.T) {
+ eng := s8eng()
+ out1, _ := s8renderErr(t, eng, `{{ x }}`, map[string]any{"x": "v"},
+ liquid.WithGlobalFilter(func(v any) (any, error) {
+ s, _ := v.(string)
+ return "!" + s, nil
+ }),
+ )
+ require.Equal(t, "!v", out1)
+ out2 := s8render(t, eng, `{{ x }}`, map[string]any{"x": "v"})
+ require.Equal(t, "v", out2)
+}
+
+// K6 — global filter applied AFTER per-node filters in the pipeline
+func TestS8_SetGlobalFilter_AppliedAfterNodeFilters(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ s, _ := v.(string)
+ return "[" + s + "]", nil
+ })
+ // upcase runs first → "HELLO", then global filter wraps it
+ out := s8render(t, eng, `{{ "hello" | upcase }}`, nil)
+ require.Equal(t, "[HELLO]", out)
+}
+
+// K7 — global filter: numeric output passes through untouched when filter is type-selective
+func TestS8_SetGlobalFilter_NumericPassthrough(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ // only transform strings
+ if s, ok := v.(string); ok {
+ return "str:" + s, nil
+ }
+ return v, nil
+ })
+ out := s8render(t, eng, `{{ 42 }}`, nil)
+ require.Equal(t, "42", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// L. SetExceptionHandler + WithErrorHandler
+// ═════════════════════════════════════════════════════════════════════════════
+
+// L1 — engine handler: replaces failing output with handler string
+func TestS8_SetExceptionHandler_ReplacesOutput(t *testing.T) {
+ eng := s8eng()
+ eng.SetExceptionHandler(func(_ error) string { return "" })
+ out := s8render(t, eng, `a{{ 1 | divided_by: 0 }}b`, nil)
+ require.Equal(t, "ab", out)
+}
+
+// L2 — engine handler: rendering continues past the failing node
+func TestS8_SetExceptionHandler_ContinuesAfterError(t *testing.T) {
+ eng := s8eng()
+ var count int
+ eng.SetExceptionHandler(func(_ error) string {
+ count++
+ return "X"
+ })
+ out := s8render(t, eng, `{{ 1 | divided_by: 0 }}{{ 2 | divided_by: 0 }}{{ 3 | divided_by: 0 }}`, nil)
+ require.Equal(t, "XXX", out)
+ require.Equal(t, 3, count)
+}
+
+// L3 — per-render WithErrorHandler: overrides engine-level handler
+func TestS8_WithErrorHandler_OverridesEngineHandler(t *testing.T) {
+ eng := s8eng()
+ eng.SetExceptionHandler(func(_ error) string { return "engine-handler" })
+ out, _ := s8renderErr(t, eng, `{{ 1 | divided_by: 0 }}`, nil,
+ liquid.WithErrorHandler(func(_ error) string { return "per-render-handler" }),
+ )
+ require.Equal(t, "per-render-handler", out)
+}
+
+// L4 — WithErrorHandler: collects errors(template.errors pattern from Ruby)
+func TestS8_WithErrorHandler_CollectsErrors(t *testing.T) {
+ eng := s8eng()
+ var errs []error
+ out, err := s8renderErr(t, eng,
+ `{{ a }}{{ 1 | divided_by: 0 }}{{ b }}{{ 2 | divided_by: 0 }}{{ c }}`,
+ map[string]any{"a": "1", "b": "2", "c": "3"},
+ liquid.WithErrorHandler(func(e error) string {
+ errs = append(errs, e)
+ return ""
+ }),
+ )
+ require.NoError(t, err)
+ require.Equal(t, "123", out)
+ require.Len(t, errs, 2, "two div-by-zero errors collected")
+}
+
+// L5 — WithErrorHandler: does not persist to next call
+func TestS8_WithErrorHandler_DoesNotPersist(t *testing.T) {
+ eng := s8eng()
+ // First call: with handler → no error
+ out, err := s8renderErr(t, eng, `{{ 1 | divided_by: 0 }}`, nil,
+ liquid.WithErrorHandler(func(_ error) string { return "caught" }),
+ )
+ require.NoError(t, err)
+ require.Equal(t, "caught", out)
+ // Second call: no handler → error
+ _, err2 := s8renderErr(t, eng, `{{ 1 | divided_by: 0 }}`, nil)
+ require.Error(t, err2)
+}
+
+// L6 — WithErrorHandler: handler receives the actual error value
+func TestS8_WithErrorHandler_ReceivesActualError(t *testing.T) {
+ eng := s8eng()
+ var got error
+ _, _ = s8renderErr(t, eng, `{{ 1 | divided_by: 0 }}`, nil,
+ liquid.WithErrorHandler(func(e error) string {
+ got = e
+ return ""
+ }),
+ )
+ require.Error(t, got)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// M. WithSizeLimit
+// ═════════════════════════════════════════════════════════════════════════════
+
+// M1 — size limit exceeded: error is returned
+func TestS8_WithSizeLimit_ExceededReturnsError(t *testing.T) {
+ eng := s8eng()
+ _, err := s8renderErr(t, eng, `1234567890`, nil, liquid.WithSizeLimit(5))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "size limit")
+}
+
+// M2 — size limit not exceeded: renders normally
+func TestS8_WithSizeLimit_WithinLimitSucceeds(t *testing.T) {
+ eng := s8eng()
+ out, err := s8renderErr(t, eng, `12345`, nil, liquid.WithSizeLimit(5))
+ require.NoError(t, err)
+ require.Equal(t, "12345", out)
+}
+
+// M3 — size limit is in bytes (not runes)
+func TestS8_WithSizeLimit_CountsBytes(t *testing.T) {
+ eng := s8eng()
+ // "Ö" is a 2-byte UTF-8 character
+ _, err := s8renderErr(t, eng, `ÖÖÖ`, nil, liquid.WithSizeLimit(5)) // 6 bytes
+ require.Error(t, err)
+
+ out, err2 := s8renderErr(t, eng, `ÖÖÖ`, nil, liquid.WithSizeLimit(6)) // exactly 6
+ require.NoError(t, err2)
+ require.Equal(t, "ÖÖÖ", out)
+}
+
+// M4 — size limit: loop-generated content is bounded
+func TestS8_WithSizeLimit_LoopContentBounded(t *testing.T) {
+ eng := s8eng()
+ // 10-iteration loop produces "1234567890" = 10 bytes
+ _, err := s8renderErr(t, eng,
+ `{% for i in (1..10) %}{{ i }}{% endfor %}`,
+ nil,
+ liquid.WithSizeLimit(5),
+ )
+ require.Error(t, err)
+}
+
+// M5 — size limit zero: no limit applied
+func TestS8_WithSizeLimit_ZeroMeansNoLimit(t *testing.T) {
+ eng := s8eng()
+ out := s8render(t, eng, `a very long template output that exceeds any sensible limit`, nil,
+ liquid.WithSizeLimit(0))
+ require.NotEmpty(t, out)
+}
+
+// M6 — size limit is per-render: does not persist across calls
+func TestS8_WithSizeLimit_PerRender_DoesNotPersist(t *testing.T) {
+ eng := s8eng()
+ // First call: limited → error
+ _, err := s8renderErr(t, eng, `1234567890`, nil, liquid.WithSizeLimit(5))
+ require.Error(t, err)
+ // Second call: no limit → succeeds
+ out := s8render(t, eng, `1234567890`, nil)
+ require.Equal(t, "1234567890", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// N. WithContext
+// ═════════════════════════════════════════════════════════════════════════════
+
+// N1 — already-cancelled context: render returns error
+func TestS8_WithContext_CancelledContext_ReturnsError(t *testing.T) {
+ eng := s8eng()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // cancel immediately
+ tpl, err := eng.ParseString(`{% for i in (1..1000) %}{{ i }}{% endfor %}`)
+ require.NoError(t, err)
+ _, renderErr := tpl.RenderString(nil, liquid.WithContext(ctx))
+ require.Error(t, renderErr)
+}
+
+// N2 — active background context: render completes normally
+func TestS8_WithContext_BackgroundContext_Passes(t *testing.T) {
+ eng := s8eng()
+ out, err := s8renderErr(t, eng, `{{ x }}`, map[string]any{"x": "ok"},
+ liquid.WithContext(context.Background()))
+ require.NoError(t, err)
+ require.Equal(t, "ok", out)
+}
+
+// N3 — expired deadline: render stops with error
+func TestS8_WithContext_ExpiredDeadline_ReturnsError(t *testing.T) {
+ eng := s8eng()
+ ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
+ defer cancel()
+ time.Sleep(time.Millisecond) // ensure expiry
+
+ tpl, err := eng.ParseString(`{% for i in (1..100000) %}{{ i }}{% endfor %}`)
+ require.NoError(t, err)
+ _, renderErr := tpl.RenderString(nil, liquid.WithContext(ctx))
+ require.Error(t, renderErr)
+}
+
+// N4 — WithContext does not persist (second call uses fresh context)
+func TestS8_WithContext_PerRender_DoesNotPersist(t *testing.T) {
+ eng := s8eng()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ tpl, err := eng.ParseString(`{% for i in (1..1000) %}{{ i }}{% endfor %}`)
+ require.NoError(t, err)
+ // First call: cancelled context → error
+ _, err1 := tpl.RenderString(nil, liquid.WithContext(ctx))
+ require.Error(t, err1)
+ // Second call: no context option → no cancellation, render with small range
+ tpl2, _ := eng.ParseString(`{{ x }}`)
+ out, err2 := tpl2.RenderString(map[string]any{"x": "fine"})
+ require.NoError(t, err2)
+ require.Equal(t, "fine", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// O. EnableCache / ClearCache
+// ═════════════════════════════════════════════════════════════════════════════
+
+// O1 — cache enabled: same source returns same *Template pointer
+func TestS8_EnableCache_SameSourceReturnsSamePointer(t *testing.T) {
+ eng := s8eng()
+ eng.EnableCache()
+ tpl1, _ := eng.ParseString(`{{ x }}`)
+ tpl2, _ := eng.ParseString(`{{ x }}`)
+ require.Same(t, tpl1, tpl2)
+}
+
+// O2 — cache enabled: different sources return different pointers
+func TestS8_EnableCache_DifferentSourcesDifferentPointers(t *testing.T) {
+ eng := s8eng()
+ eng.EnableCache()
+ tpl1, _ := eng.ParseString(`{{ x }}`)
+ tpl2, _ := eng.ParseString(`{{ y }}`)
+ require.NotSame(t, tpl1, tpl2)
+}
+
+// O3 — cache: rendering result is still correct after cache hit
+func TestS8_EnableCache_CachedTemplateRendersCorrectly(t *testing.T) {
+ eng := s8eng()
+ eng.EnableCache()
+ for i := range 4 {
+ out := s8render(t, eng, `{{ v }}`, map[string]any{"v": i})
+ require.Equal(t, fmt.Sprintf("%d", i), out)
+ }
+}
+
+// O4 — ClearCache: after clear, same source parses fresh (different pointer)
+func TestS8_ClearCache_NewPointerAfterClear(t *testing.T) {
+ eng := s8eng()
+ eng.EnableCache()
+ tpl1, _ := eng.ParseString(`{{ x }}`)
+ eng.ClearCache()
+ tpl2, _ := eng.ParseString(`{{ x }}`)
+ require.NotSame(t, tpl1, tpl2)
+}
+
+// O5 — cache: concurrent access is safe
+func TestS8_EnableCache_ConcurrentAccessSafe(t *testing.T) {
+ eng := s8eng()
+ eng.EnableCache()
+
+ var wg sync.WaitGroup
+ for range 30 {
+ wg.Go(func() {
+ out := s8render(t, eng, `{{ v }}`, map[string]any{"v": "ok"})
+ assert.Equal(t, "ok", out)
+ })
+ }
+ wg.Wait()
+}
+
+// O6 — cache disabled by default: always parses fresh
+func TestS8_Cache_DisabledByDefault_AlwaysFresh(t *testing.T) {
+ eng := s8eng()
+ tpl1, _ := eng.ParseString(`{{ x }}`)
+ tpl2, _ := eng.ParseString(`{{ x }}`)
+ require.NotSame(t, tpl1, tpl2)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// P. EnableJekyllExtensions
+// ═════════════════════════════════════════════════════════════════════════════
+
+// P1 — dot assign: assign to a dotted path
+func TestS8_JekyllExtensions_DotAssign(t *testing.T) {
+ eng := s8eng()
+ eng.EnableJekyllExtensions()
+ out := s8render(t, eng, `{% assign page.title = "Home" %}{{ page.title }}`, nil)
+ require.Equal(t, "Home", out)
+}
+
+// P2 — dot assign: standard assign still works when extensions enabled
+func TestS8_JekyllExtensions_StandardAssignStillWorks(t *testing.T) {
+ eng := s8eng()
+ eng.EnableJekyllExtensions()
+ out := s8render(t, eng, `{% assign x = "hello" %}{{ x }}`, nil)
+ require.Equal(t, "hello", out)
+}
+
+// P3 — without Jekyll extensions: dot-assign is a parse error
+func TestS8_JekyllExtensions_Disabled_DotAssignErrors(t *testing.T) {
+ eng := s8eng()
+ _, err := eng.ParseString(`{% assign page.title = "Home" %}`)
+ require.Error(t, err)
+}
+
+// P4 — dot assign with multiple segments
+func TestS8_JekyllExtensions_DotAssign_MultipleSegments(t *testing.T) {
+ eng := s8eng()
+ eng.EnableJekyllExtensions()
+ out := s8render(t, eng, `{% assign a.b.c = "deep" %}{{ a.b.c }}`, nil)
+ require.Equal(t, "deep", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// Q. SetAutoEscapeReplacer
+// ═════════════════════════════════════════════════════════════════════════════
+
+// Q1 — HTML escaper: & < > " ' are escaped in output
+func TestS8_SetAutoEscapeReplacer_EscapesHTML(t *testing.T) {
+ eng := s8eng()
+ eng.SetAutoEscapeReplacer(render.HtmlEscaper)
+ out := s8render(t, eng, `{{ s }}`, map[string]any{"s": ``})
+ require.Equal(t, `<script>alert("xss")</script>`, out)
+}
+
+// Q2 — HTML escaper: literal text is not escaped
+func TestS8_SetAutoEscapeReplacer_LiteralTextUnchanged(t *testing.T) {
+ eng := s8eng()
+ eng.SetAutoEscapeReplacer(render.HtmlEscaper)
+ out := s8render(t, eng, `literal {{ v }}`, map[string]any{"v": ""})
+ require.Equal(t, `literal <b>`, out)
+}
+
+// Q3 — HTML escaper: raw filter bypasses escaping
+func TestS8_SetAutoEscapeReplacer_RawFilterBypasses(t *testing.T) {
+ eng := s8eng()
+ eng.SetAutoEscapeReplacer(render.HtmlEscaper)
+ out := s8render(t, eng, `{{ s | raw }}`, map[string]any{"s": `bold `})
+ require.Equal(t, `bold `, out)
+}
+
+// Q4 — HTML escaper: ampersands are double-escaped only once
+func TestS8_SetAutoEscapeReplacer_AmpersandEscapedOnce(t *testing.T) {
+ eng := s8eng()
+ eng.SetAutoEscapeReplacer(render.HtmlEscaper)
+ out := s8render(t, eng, `{{ s }}`, map[string]any{"s": "a & b"})
+ require.Equal(t, "a & b", out)
+}
+
+// Q5 — without escaper (default): HTML characters pass through raw
+func TestS8_NoAutoEscape_HTMLPassesThrough(t *testing.T) {
+ eng := s8eng()
+ out := s8render(t, eng, `{{ s }}`, map[string]any{"s": "bold "})
+ require.Equal(t, "bold ", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// R. NewBasicEngine
+// ═════════════════════════════════════════════════════════════════════════════
+
+// R1 — NewBasicEngine: no standard filters registered
+func TestS8_NewBasicEngine_NoStandardFilters(t *testing.T) {
+ eng := liquid.NewBasicEngine()
+ _, err := eng.ParseString(`{{ "hello" | upcase }}`)
+ if err == nil {
+ // some engines may parse ok but fail at render
+ _, renderErr := eng.ParseAndRenderString(`{{ "hello" | upcase }}`, nil)
+ require.Error(t, renderErr)
+ }
+}
+
+// R2 — NewBasicEngine: variable lookup still works
+func TestS8_NewBasicEngine_VariableLookupWorks(t *testing.T) {
+ eng := liquid.NewBasicEngine()
+ out, err := eng.ParseAndRenderString(`{{ x }}`, map[string]any{"x": "hello"})
+ require.NoError(t, err)
+ require.Equal(t, "hello", out)
+}
+
+// R3 — NewBasicEngine: standard tags not available
+func TestS8_NewBasicEngine_NoStandardTags(t *testing.T) {
+ eng := liquid.NewBasicEngine()
+ _, err := eng.ParseString(`{% if true %}yes{% endif %}`)
+ require.Error(t, err)
+}
+
+// R4 — NewBasicEngine: custom filter registration works
+func TestS8_NewBasicEngine_CustomFilterRegistration(t *testing.T) {
+ eng := liquid.NewBasicEngine()
+ eng.RegisterFilter("double", func(s string) string { return s + s })
+ out, err := eng.ParseAndRenderString(`{{ "ab" | double }}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "abab", out)
+}
+
+// R5 — NewBasicEngine: custom tag registration works
+func TestS8_NewBasicEngine_CustomTagRegistration(t *testing.T) {
+ eng := liquid.NewBasicEngine()
+ eng.RegisterTag("hello", func(_ render.Context) (string, error) { return "hi", nil })
+ out, err := eng.ParseAndRenderString(`{% hello %}`, nil)
+ require.NoError(t, err)
+ require.Equal(t, "hi", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// S. Combinations
+// ═════════════════════════════════════════════════════════════════════════════
+
+// S1 — WithStrictVariables + WithErrorHandler: strict errors are caught by handler
+func TestS8_StrictVars_Plus_ErrorHandler(t *testing.T) {
+ eng := s8eng()
+ var caught error
+ out, err := s8renderErr(t, eng,
+ `{{ good }}{{ bad }}`,
+ map[string]any{"good": "ok"},
+ liquid.WithStrictVariables(),
+ liquid.WithErrorHandler(func(e error) string {
+ caught = e
+ return ""
+ }),
+ )
+ require.NoError(t, err)
+ require.Equal(t, "ok", out)
+ require.Error(t, caught)
+ require.Contains(t, caught.Error(), "bad")
+}
+
+// S2 — GlobalFilter + SizeLimit: filter expands output → hits limit
+func TestS8_GlobalFilter_Plus_SizeLimit_PrefixedOutputHitsLimit(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ // each output is prefixed with "prefix:" — grows output
+ s, _ := v.(string)
+ return "prefix:" + s, nil
+ })
+ // "prefix:x" = 8 bytes; limit of 5 should fail
+ _, err := s8renderErr(t, eng, `{{ x }}`, map[string]any{"x": "x"}, liquid.WithSizeLimit(5))
+ require.Error(t, err)
+}
+
+// S3 — LaxTags + StrictVariables: lax tags ignore unknowns, strict vars still fire
+func TestS8_LaxTags_Plus_StrictVariables(t *testing.T) {
+ eng := s8eng()
+ eng.LaxTags()
+ eng.StrictVariables()
+ // Unknown tag → ignored; undefined var → error
+ _, err := s8renderErr(t, eng, `{% ghost_tag %}{{ undefined_var }}`, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "undefined")
+}
+
+// S4 — Globals + GlobalFilter + ErrorHandler together
+func TestS8_Globals_GlobalFilter_ErrorHandler_Together(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"prefix": ">"})
+ eng.SetGlobalFilter(func(v any) (any, error) {
+ s, _ := v.(string)
+ return "[" + s + "]", nil
+ })
+ var errs []error
+ out := s8render(t, eng, `{{ prefix }}: {{ name }}`, map[string]any{"name": "Alice"},
+ liquid.WithErrorHandler(func(e error) string {
+ errs = append(errs, e)
+ return ""
+ }),
+ )
+ require.Equal(t, "[>]: [Alice]", out)
+ require.Empty(t, errs)
+}
+
+// S5 — cache + custom filter: cached template uses same filter table
+func TestS8_Cache_Plus_CustomFilter(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterFilter("shout", func(s string) string { return s + "!" })
+ eng.EnableCache()
+
+ out1 := s8render(t, eng, `{{ "hi" | shout }}`, nil)
+ out2 := s8render(t, eng, `{{ "hi" | shout }}`, nil)
+ require.Equal(t, "hi!", out1)
+ require.Equal(t, "hi!", out2)
+}
+
+// S6 — custom delimiters + globals + custom filter
+func TestS8_CustomDelims_Globals_CustomFilter(t *testing.T) {
+ eng := s8eng()
+ // Delims(objectLeft, objectRight, tagLeft, tagRight) — output=[[ ]], tag=[% %]
+ eng.Delims("[[", "]]", "[%", "%]")
+ eng.SetGlobals(map[string]any{"site": "Acme"})
+ eng.RegisterFilter("badge", func(s string) string { return "(" + s + ")" })
+ out := s8render(t, eng, `[% if x %][[ site | badge ]][% endif %]`, map[string]any{"x": true})
+ require.Equal(t, "(Acme)", out)
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// T. Real-world scenarios
+// ═════════════════════════════════════════════════════════════════════════════
+
+// T1 — blog page layout: globals, includes, and custom filter
+func TestS8_RealWorld_BlogPageLayout(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{
+ "site_name": "My Blog",
+ "current_year": 2025,
+ })
+ eng.RegisterFilter("slugify", func(s string) string {
+ return strings.ToLower(strings.ReplaceAll(s, " ", "-"))
+ })
+ eng.RegisterTemplateStore(&mapStore{files: map[string]string{
+ "_header.html": `{{ page_title }} - {{ site_name }} `,
+ "_footer.html": `© {{ current_year }} {{ site_name }} `,
+ }})
+
+ tpl := `{% include "_header.html" %}{{ body }}{% include "_footer.html" %}`
+ out := s8render(t, eng, tpl, map[string]any{
+ "page_title": "About Us",
+ "body": "content ",
+ })
+ require.Equal(t, "About Us - My Blog content ", out)
+}
+
+// T2 — error recovery report: collect all render errors with their substitution
+func TestS8_RealWorld_ErrorRecoveryReport(t *testing.T) {
+ eng := s8eng()
+ var errs []error
+ out := s8render(t, eng,
+ `item1={{ a }}, item2={{ 1 | divided_by: 0 }}, item3={{ b }}, item4={{ 2 | divided_by: 0 }}`,
+ map[string]any{"a": "A", "b": "B"},
+ liquid.WithErrorHandler(func(e error) string {
+ errs = append(errs, e)
+ return "ERR"
+ }),
+ )
+ require.Equal(t, "item1=A, item2=ERR, item3=B, item4=ERR", out)
+ require.Len(t, errs, 2)
+}
+
+// T3 — custom auth tag checks context variable before rendering content
+func TestS8_RealWorld_CustomAuthTag(t *testing.T) {
+ eng := s8eng()
+ eng.RegisterTag("require_role", func(ctx render.Context) (string, error) {
+ role := ctx.TagArgs()
+ v := ctx.Get("user_role")
+ if fmt.Sprintf("%v", v) != role {
+ return fmt.Sprintf("", role), nil
+ }
+ return "", nil
+ })
+
+ tpl := `{% require_role admin %}secret content`
+ // Authorized user
+ out1 := s8render(t, eng, tpl, map[string]any{"user_role": "admin"})
+ require.Equal(t, "secret content", out1)
+
+ // Unauthorized user
+ out2 := s8render(t, eng, tpl, map[string]any{"user_role": "viewer"})
+ require.Equal(t, "secret content", out2)
+}
+
+// T4 — concurrent rendering with per-render global filters does not cross-contaminate
+func TestS8_RealWorld_ConcurrentGlobalFilters_NoContamination(t *testing.T) {
+ eng := s8eng()
+ tpl, err := eng.ParseString(`{{ v }}`)
+ require.NoError(t, err)
+
+ var wg sync.WaitGroup
+ results := make([]string, 20)
+ for i := range 20 {
+ wg.Add(1)
+ i := i
+ go func() {
+ defer wg.Done()
+ tag := fmt.Sprintf("worker%d", i)
+ out, _ := tpl.RenderString(map[string]any{"v": "x"},
+ liquid.WithGlobalFilter(func(v any) (any, error) {
+ return tag + ":" + fmt.Sprintf("%v", v), nil
+ }),
+ )
+ results[i] = out
+ }()
+ }
+ wg.Wait()
+
+ for i, got := range results {
+ require.Equal(t, fmt.Sprintf("worker%d:x", i), got)
+ }
+}
+
+// T5 — per-render WithGlobals is safe under concurrent use
+func TestS8_RealWorld_ConcurrentPerRenderGlobals(t *testing.T) {
+ eng := s8eng()
+ eng.SetGlobals(map[string]any{"base": "base"})
+ tpl, err := eng.ParseString(`{{ base }}-{{ extra }}`)
+ require.NoError(t, err)
+
+ var wg sync.WaitGroup
+ for i := range 20 {
+ wg.Add(1)
+ i := i
+ go func() {
+ defer wg.Done()
+ want := fmt.Sprintf("base-extra%d", i)
+ out, renderErr := tpl.RenderString(nil,
+ liquid.WithGlobals(map[string]any{"extra": fmt.Sprintf("extra%d", i)}),
+ )
+ assert.NoError(t, renderErr)
+ assert.Equal(t, want, out)
+ }()
+ }
+ wg.Wait()
+}
+
+// T6 — HTML auto-escape: XSS prevention in a form template
+func TestS8_RealWorld_AutoEscapeXSSPrevention(t *testing.T) {
+ eng := s8eng()
+ eng.SetAutoEscapeReplacer(render.HtmlEscaper)
+
+ // UserInput contains XSS payload
+ out := s8render(t, eng,
+ ` `,
+ map[string]any{"user_input": `">`},
+ )
+ // The injected payload must be escaped so it cannot break out of the attribute
+ require.NotContains(t, out, "