diff --git a/.gitignore b/.gitignore index 7abd1c9e..823175cd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.out /liquid *.test +.example-repositories diff --git a/.plans/implementation-checklist.md b/.plans/implementation-checklist.md new file mode 100644 index 00000000..99e57dec --- /dev/null +++ b/.plans/implementation-checklist.md @@ -0,0 +1,457 @@ +# Implementation Checklist — Go Liquid vs Merged Reference + +> Comparison between [go-liquid-reference.md](unchangeable-refs/go-liquid-reference.md) and [merged-liquid-reference.md](unchangeable-refs/merged-ruby-js-liquid-reference.md). +> +> **Status columns (in order: Impl · Tests · E2E):** +> +> | Column | Meaning | +> |--------|---------| +> | **Impl** | Implementation complete (✅ correct · ⚠️ behavior differs from spec · ❌ not implemented) | +> | **Tests** | Tests ported from references (Ruby and/or JS) passing | +> | **E2E** | Own intensive tests covering the feature (never run automatically — only when user explicitly requests) | +> +> **Values:** `✅` done · `⬜` pending · `➖` not applicable +> +> **Priority legend:** +> - **P1** — Core Shopify Liquid (present in Ruby _and_ JS; any valid Liquid needs this) +> - **P2** — Common extension (present in both but not Shopify core; e.g. Jekyll filters that both have) +> - **P3** — Ruby Liquid exclusive +> - **P4** — LiquidJS exclusive +> - **P5** — Nice-to-have / low priority +> +> **DECISION MADE** — items where Ruby, JS, or Go diverge and we have already decided which behavior will prevail here in the Go version. +> +> If you need to check where features are implemented in JS or Ruby, see [merged-liquid-reference.md](./unchangeable-refs/merged-ruby-js-liquid-reference.md). +> If you can't find it there, feel free to search directly in the original repositories cloned locally in .example-repositories + +--- + +## 0. Bugs — Fixes to existing behavior + +> These items do not require new structures. They can be investigated and fixed independently. + +### B1 · Go numeric types in comparisons + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `uint64`, `uint32`, `int8`, etc. in `{% if %}` and operators | `NormalizeNumber()` added in `values/compare.go`: converts all Go integer/float types to `int64`/`uint64`/`float64` before any comparison. `numericCompare()` does precise comparison without falling back to float64 for the int64/uint64 pair, preserving precision for values > MaxInt64. Array indexing and loop bounds (`for i in list limit: n`) also failed for unsigned types — both fixed. E2E tests in `b1_numeric_types_test.go` cover: all operators (`==`,`!=`,`<`,`>`,`<=`,`>=`), `if`/`unless`/`case-when`, composite conditions `and`/`or`, struct fields with uint type, filters `abs`/`at_least`/`at_most`/`ceil`/`floor`/`round`, filter chains, `sort`/`where` on mixed arrays, array indexing with uint variable, `assign`+comparison, `for` with `limit`/`offset` uint, float precision. Two additional bugs fixed: `arrayValue.IndexValue` and `toLoopInt` in `iteration_tags.go` did not accept uint types. | + +### B2 · Truthiness: `nil`, `false`, `blank`, `empty` + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | Falsy rules in `{% if %}` | Implementation verified and correct. `wrapperValue.Test()` in `values/value.go` uses `v.value != nil && v.value != false`; `if/unless` in `control_flow_tags.go` uses `value != nil && value != false`; `and`/`or`/`not` in `expressions.y` use `.Test()`. `IsEmpty` and `IsBlank` in `values/predicates.go` are used only for comparisons with `empty`/`blank` keyword, not for general truthiness. `default` filter uses `IsEmpty` correctly (activates for `""`, `[]`, `{}`, `nil`, `false`; does NOT activate for `0` or non-empty strings). Ported tests: `TestPortedLiterals_Truthiness`, `TestPortedLiterals_Empty`, `TestPortedLiterals_Blank` in `expressions_ported_test.go` (46 tests). Intensive E2E in `b2_truthiness_test.go` (63 tests) covering: typed Go bindings, `if`/`unless`/`not`/`and`/`or`, `case/when` with nil/false, `default` filter with all edge cases including `allow_false`, `where` filter without value (truthy), comparisons with `blank` and `empty` via variables, `capture`/`assign`, and `elsif` chains. | + +### B3 · Whitespace control in edge cases + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `{%-`/`-%}` and `{{-`/`-}}` in nested blocks and loops | **Bug fixed:** scanner in `parser/scanner.go` did not recognize `{%- # comment -%}` (space between `-` and `#`) — the inline comment regex `{%-?#` was updated to `{%-?\s*#`, allowing an optional space. This also enabled `{% # comment %}` (space without trim). Existing `TestInlineComment` tests expanded with 6 spacing variants. Behavior of `trimWriter` in loops and nested blocks confirmed correct: trim nodes in the `for` body execute per iteration; global `TrimTagLeft/Right` only affects the external context of the block, not the interior of iterations. Ported tests already covered the Ruby/LiquidJS cases. Intensive E2E in `b3_whitespace_ctrl_test.go` (38 tests) covers: `for` with all trim combinations, `for`+`else`, `if` nested in `for`, double nesting, `unless`/`case`/`when` with trim, `assign`/`capture` with trim, inline comment with space (bug fixed), `{{- -}}` inside loops, global `TrimTagLeft/Right/Both`, `greedy`/`non-greedy`, `liquid` tag with trim, and `raw` with internal trim markers. | + +### B4 · Error messages and types + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | Distinct error types (`ParseError`, `RenderError`, `UndefinedVariableError`) | Implemented via swarm PRE-E: `ParseError` in `parser/error.go`, `RenderError` and `UndefinedVariableError` in `render/error.go`. `UndefinedVariableError` carries the literal variable name. `ZeroDivisionError` also implemented in `filters/standard_filters.go`. **Intensive E2E tests in `b4_b6_error_test.go`** (55 tests) cover: `ParseError` (prefix, `errors.As`, `LineNumber`, `MarkupContext`, `Message`), `RenderError` (prefix, `errors.As`, `LineNumber`, `MarkupContext`, `Cause`), `UndefinedVariableError` (Name, LineNumber, Message, MarkupContext, StrictVariables), `ZeroDivisionError`, `ArgumentError` (filters + tags + line + correct context), `ContextError`, and the entire B6 suite of context preservation. | + +### B5 · Renderer not safe for concurrent use + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `render.Context` shares mutable state between concurrent calls | **Investigation completed in `b5_concurrency_test.go`.** Result: the **render path is safe** for concurrent use — each call creates its own `nodeContext` with an isolated `bindings`; stateful tags (increment, assign, cycle, for-continue) operate only on the local map; compiled expressions are read-only; `sync.Once` in `Variables()` is thread-safe. **Bug confirmed**: `e.cfg.Cache map[string][]byte` in `render/config.go` is not concurrency-safe — `ParseTemplateAndCache` writes to the same map that `{% include %}` reads during rendering, causing `fatal error: concurrent map writes`. **Fix**: replace `Cache map[string][]byte` with `sync.Map` at 3 sites (`engine.go:242`, `render/context.go:200`, `render/context.go:234`). **Performance confirmed via benchmarks**: pure render of shared template scales nearly linearly (8.7k→3.2k→2.2 ns/op at 1→4→8 CPUs ✅). Parse under high concurrency does not scale (27k→21k→26k, plateaus) due to GC allocation pressure — there are +177 allocs/op per parse vs pure render. **Recommended patterns** (most to least efficient): (1) parse once, share `*Template`, render in N goroutines (~2k ns/op×N); (2) shared engine with cache enabled (`EnableCache()`) — same performance; (3) shared engine without cache, parse+render per call (~26k ns/op); (4) ❌ engine per goroutine — 6× slower (~50k ns/op) due to GC overhead from recreating filter/grammar maps. | + +### B6 · Variable error messages degraded by indentation and block context + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | Undefined variable errors with vague messages in `{% if %}` and other blocks | **Bug identified and fixed.** Root cause: `wrapRenderError` in `render/error.go` re-wrapped any `*RenderError` without `Path()` even when it already had `LineNumber > 0`. This caused `BlockNode` (if/for/unless/case) to overwrite the `MarkupContext` of the inner node (`{{ expr }}`) with the source of the parent block (`{% if ... %}`). **Fix:** added `re.LineNumber() > 0` to the preservation condition in `wrapRenderError` — if the error already has a line number, it came from a more specific node (ObjectNode/TagNode) and should be preserved. Single-line and multi-line templates now produce identical messages pointing to the exact node. Errors in block conditions (e.g. `{% if x | divided_by: 0 %}`) are still correctly attributed to `{% if %}`. Intensive tests in `b4_b6_error_test.go`. | + +--- + +## 1. Tags + +### 1.1 Output / Expression + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `{{ expression }}` | OK. Ported tests in `tags_ported_test.go` (`TestPorted_Output_*`). | +| ✅ | ✅ | ✅ | P1 | `echo` tag | `{% echo expr %}` — equivalent to `{{ }}`, but usable inside `{% liquid %}`. Ruby: always emits. JS: value optional (no value emits nothing). **DECISION MADE:** follow Ruby (value always required). Ported tests in `tags_ported_test.go` (`TestPorted_Echo_*`). | +| ✅ | ✅ | ✅ | P1 | `liquid` tag (multi-line) | Implemented in `tags/standard_tags.go`. Each non-empty, non-comment line is compiled as `{%...%}` and rendered in the current context (assign propagates). Lines starting with `#` are comments. Syntax errors propagate at compile-time. Tests in `TestLiquidTag`. | +| ✅ | ✅ | ✅ | P1 | `#` inline comment | Implemented in scanner (`parser/scanner.go`): pattern `{%-?#(?:...)%}` added to the tokenization regex. Trim markers (`{%-#` and `{%#-%}`) work. Tests in `TestInlineComment`. | + +### 1.2 Variable / State + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `assign` | OK. Jekyll dot notation (`assign page.prop = v`) also implemented. Ported tests in `tags_ported_test.go` (`TestPorted_Assign_*`). | +| ✅ | ✅ | ✅ | P1 | `capture` | OK. **Bug fix:** `{% capture 'var' %}` and `{% capture "var" %}` (quoted variable name) now work correctly — quotes are stripped before assigning. Ported tests in `tags_ported_test.go` (`TestPorted_Capture_*`). | +| ✅ | ✅ | ✅ | P1 | `increment` | Implemented in `tags/standard_tags.go`. Counter separate from `assign` and `decrement`. Starts at 0, emits the current value and increments. Tests in `TestIncrementDecrement`. | +| ✅ | ✅ | ✅ | P1 | `decrement` | Implemented in `tags/standard_tags.go`. Counter separate from `assign` and `increment`. Starts at 0, decrements and emits the new value (first call = -1). Tests in `TestIncrementDecrement`. | + +### 1.3 Conditionals + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `if` / `elsif` / `else` / `endif` | OK. Ported tests in `tags_ported_test.go` (`TestPorted_If_*`). | +| ✅ | ✅ | ✅ | P1 | `unless` / `else` / `endunless` | OK. Note: `unless` + `elsif` is not supported (Ruby also raises an error). Ported tests in `tags_ported_test.go` (`TestPorted_Unless_*`). | +| ✅ | ✅ | ✅ | P1 | `case` / `when` / `else` / `endcase` — `or` in `when` | `when val1 or val2` — supported. Implemented in the yacc grammar (`expressions.y`). Ported tests in `tags_ported_test.go` (`TestPorted_Case_*`). | +| ✅ | ✅ | ✅ | P3 | `ifchanged` | Implemented in `tags/standard_tags.go` via `ifchangedCompiler`. Captures the rendered content of the block and only emits if it changed since the last call. State in `"\x00ifchanged_last"`. Tests in `TestIfchangedTag`. | + +### 1.4 Iteration + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `for` / `else` / `endfor` with `limit`, `offset`, `reversed`, range | OK. **Bug fix:** `for` with `nil` collection now correctly renders the `else` branch. Ported tests in `tags_ported_test.go` (`TestPorted_For_*`). | +| ✅ | ✅ | ✅ | P1 | `for` — modifier application order | **Fixed.** Ruby always applies `offset → limit → reversed` (regardless of user-declared order). Previously, Go applied them in a different fixed order. Now: `applyLoopModifiers` in `tags/iteration_tags.go` applies offset→limit first, then reversed. Verification tests in `tags_ported_test.go` (`TestPorted_For_Modifiers_*`). | +| ✅ | ✅ | ✅ | P4 | `for` — `offset: continue` | Implemented in `tags/iteration_tags.go`. Detected via regex before parsing. ALL for-loops track the final position in `"\x00for_continue_variable-collection"`. Loops with `offset:continue` resume from there. Tests in `TestOffsetContinue`. | +| ✅ | ✅ | ✅ | P1 | `break` / `continue` | OK. Ported tests in `tags_ported_test.go` (`TestPorted_For_Break_*`, `TestPorted_For_Continue_*`). | +| ✅ | ✅ | ✅ | P1 | `cycle` with named group | OK. Note: `cycle` outside `for` is not supported (requires `forloop` in context). Ported tests in `tags_ported_test.go` (`TestPorted_Cycle_*`). | +| ✅ | ✅ | ✅ | P1 | `tablerow` with `cols`, `limit`, `offset`, range | OK. Note: loop variables accessible as `forloop.xxx` (not `tablerowloop.xxx`). HTML emitted without newline between `` and ``. Ported tests in `tags_ported_test.go` (`TestPorted_Tablerow_*`). | + +### 1.5 Template inclusion + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `include` — basic syntax `{% include "file" %}` | Implemented and tested. | +| ✅ | ✅ | ✅ | P1 | `include` — `with var [as alias]` | Implemented in `tags/include_tag.go` with a dedicated parser. Tests in `TestIncludeTag_with_variable` and `TestIncludeTag_with_alias`. | +| ✅ | ✅ | ✅ | P1 | `include` — `key: val` args | Implemented in `tags/include_tag.go` with `parseKVPairs`. Tests in `TestIncludeTag_kv_pairs`. | +| ✅ | ✅ | ✅ | P3 | `include` — `for array as alias` | Implemented in `tags/include_tag.go`. `{% include 'file' for items as item %}` iterates the collection and renders the file once per item with `item` in the shared scope. Tests in `TestIncludeTag_for_array`. | +| ✅ | ✅ | ✅ | P1 | `render` tag | Implemented in `tags/render_tag.go`. Supports isolated scope, `with var [as alias]`, `key: val` args, and `for collection as item`. Tests in `TestRenderTag_*`. | + +### 1.6 Structure / Text + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `raw` / `endraw` | OK. Ported tests in `tags_ported_test.go` (`TestPorted_Raw_*`). | +| ✅ | ✅ | ✅ | P1 | `comment` — nesting | Go: any token ignored inside comment (parser consumes until `endcomment`). Ruby: explicitly supports nested `comment` and `raw`. Effective behavior is identical for normal use — no code changes needed. Ported tests in `tags_ported_test.go` (`TestPorted_Comment_*`). | +| ✅ | ✅ | ✅ | P3 | `doc` / `enddoc` | Implemented. `c.AddBlock("doc")` in `standard_tags.go` + special handling in parser (`parser/parser.go`) same as `comment` — internal content is completely ignored at parse-time. Tests in `TestDocTag`. | +| ✅ | ✅ | ✅ | P4 | `layout` / `block` | Implemented in `tags/layout_tags.go`. `{% layout 'file' %}...{% endlayout %}` captures child blocks and renders the layout with overrides. `{% block name %}default{% endblock %}` in the child defines override; in the layout defines a slot with fallback. Requires `render/context.go` updated to support `RenderFile` in block context. Tests in `TestLayoutTag*` and `TestBlockTag_standalone`. | + +--- + +## 2. Filters + +### 2.1 String + +| Impl | Tests | E2E | Priority | Item | Notes | +|------|-------|-----|----------|------|-------| +| ✅ | ✅ | ✅ | P1 | `downcase`, `upcase` | Ported tests in `filters_ported_test.go`. | +| ✅ | ✅ | ✅ | P1 | `capitalize` | Fix applied: first char uppercase + rest lowercase. Ported tests (`"MY GREAT TITLE"` → `"My great title"`). | +| ✅ | ✅ | ✅ | P1 | `append`, `prepend` | Ported tests in `filters_ported_test.go`. | +| ✅ | ✅ | ✅ | P1 | `remove`, `remove_first`, `remove_last` | Ported tests in `filters_ported_test.go`. | +| ✅ | ✅ | ✅ | P1 | `replace`, `replace_first`, `replace_last` | Ported tests in `filters_ported_test.go`. | +| ✅ | ✅ | ✅ | P1 | `split` | Trailing empty strings removed (correct). Ported tests in `filters_ported_test.go`. | +| ✅ | ✅ | ✅ | P4 | `lstrip`, `rstrip`, `strip` — optional `chars` argument | Implemented: each filter accepts optional `chars func(string) string`. Ported tests in `filters/standard_filters_test.go`. | +| ✅ | ✅ | ✅ | P1 | `strip_html` | Fix applied: removes `" | xml_escape`, + "<script>"alert"</script>") +} + +// Verify we don't emit negative test count for generated test names. +func TestPortedFilters_SliceEdgeCases(t *testing.T) { + cases := []struct { + expr string + expected any + }{ + // [ruby: test_slice] — more edge cases + {`"foobar" | slice: 0, -1`, ""}, // negative length → 0 length + {`"foobar" | slice: -100`, "f"}, // clamps start to 0, length defaults to 1 + {`"foobar" | slice: 100`, ""}, // start beyond end + {`"foobar" | slice: 100, 200`, ""}, + } + for i, tc := range cases { + t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) { + portedFilterEval(t, tc.expr, tc.expected) + }) + } +} diff --git a/filters/sort_filters.go b/filters/sort_filters.go index 0d478026..47b9ae5d 100644 --- a/filters/sort_filters.go +++ b/filters/sort_filters.go @@ -16,7 +16,7 @@ func sortFilter(array []any, key any) []any { if key == nil { values.Sort(result) } else { - values.SortByProperty(result, fmt.Sprint(key), true) + values.SortByProperty(result, fmt.Sprint(key), false) } return result @@ -29,25 +29,61 @@ func sortNaturalFilter(array []any, key any) any { switch { case reflect.ValueOf(array).Len() == 0: case key != nil: - sort.Sort(keySortable{result, func(m any) string { - rv := reflect.ValueOf(m) - if rv.Kind() != reflect.Map { + sort.SliceStable(result, func(i, j int) bool { + getKey := func(m any) string { + rv := reflect.ValueOf(m) + if rv.Kind() != reflect.Map { + return "" + } + ev := rv.MapIndex(reflect.ValueOf(key)) + if ev.IsValid() && ev.CanInterface() { + if s, ok := ev.Interface().(string); ok { + return strings.ToLower(s) + } + } return "" } - - ev := rv.MapIndex(reflect.ValueOf(key)) - if ev.CanInterface() { - if s, ok := ev.Interface().(string); ok { - return strings.ToLower(s) - } + ki, kj := getKey(result[i]), getKey(result[j]) + // Empty key (nil or missing) goes last. + if ki == "" && kj == "" { + return false } - - return "" - }}) - case reflect.TypeOf(array[0]).Kind() == reflect.String: - sort.Sort(keySortable{result, func(s any) string { - return strings.ToUpper(s.(string)) - }}) + if ki == "" { + return false + } + if kj == "" { + return true + } + return ki < kj + }) + default: + // Find the first non-nil element to determine the element type. + firstNonNil := -1 + for i, v := range result { + if v != nil { + firstNonNil = i + break + } + } + if firstNonNil == -1 { + // All nils — nothing to sort. + break + } + if reflect.TypeOf(result[firstNonNil]).Kind() == reflect.String { + sort.SliceStable(result, func(i, j int) bool { + a, b := result[i], result[j] + if a == nil && b == nil { + return false + } + if a == nil { + return false // nil goes last + } + if b == nil { + return true + } + return strings.ToUpper(a.(string)) < strings.ToUpper(b.(string)) + }) + } } return result diff --git a/filters/standard_filters.go b/filters/standard_filters.go index 2a9bbfc0..9aad2bf1 100644 --- a/filters/standard_filters.go +++ b/filters/standard_filters.go @@ -2,74 +2,73 @@ package filters import ( + "encoding/base64" "encoding/json" - "errors" "fmt" "html" "math" "net/url" "reflect" - "strconv" "regexp" + "strconv" "strings" "time" "unicode" + "unicode/utf8" "github.com/osteele/tuesday" + "github.com/osteele/liquid/expressions" "github.com/osteele/liquid/values" ) -var errDivisionByZero = errors.New("division by zero") +// ZeroDivisionError is returned by the divided_by and modulo filters when +// the divisor is zero. Use errors.As to detect this specific condition. +type ZeroDivisionError struct{} + +func (e *ZeroDivisionError) Error() string { return "divided by 0" } // A FilterDictionary holds filters. type FilterDictionary interface { AddFilter(string, any) + AddContextFilter(string, expressions.ContextFilterFn) } // Helper functions for type-aware arithmetic operations // isIntegerType checks if a value is an integer type that can be safely -// represented as int64 without overflow +// represented as int64 without overflow. Uses reflect.Kind so defined types +// (e.g. type MyInt int32) are recognised the same as their underlying types. func isIntegerType(v any) bool { - switch val := v.(type) { - case int, int8, int16, int32, int64, uint8, uint16, uint32: + if v == nil { + return false + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return true + case reflect.Uint8, reflect.Uint16, reflect.Uint32: return true - case uint: - // Check if uint value fits in int64 range - return val <= math.MaxInt64 - case uint64: - // Check if uint64 value fits in int64 range - return val <= math.MaxInt64 + case reflect.Uint, reflect.Uint64, reflect.Uintptr: + return rv.Uint() <= math.MaxInt64 default: return false } } -// toInt64 converts a value to int64 -// Caller must ensure value fits in int64 range by calling isIntegerType first +// toInt64 converts a value to int64. +// Caller must ensure value fits in int64 range by calling isIntegerType first. +// Uses reflect.Kind so defined types (e.g. type MyInt int32) are handled. func toInt64(v any) int64 { - switch val := v.(type) { - case int: - return int64(val) - case int8: - return int64(val) - case int16: - return int64(val) - case int32: - return int64(val) - case int64: - return val - case uint8: - return int64(val) - case uint16: - return int64(val) - case uint32: - return int64(val) - case uint: - return int64(val) //nolint:gosec // G115: Safe - isIntegerType verifies val <= math.MaxInt64 - case uint64: - return int64(val) //nolint:gosec // G115: Safe - isIntegerType verifies val <= math.MaxInt64 + if v == nil { + return 0 + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return int64(rv.Uint()) //nolint:gosec // G115: Safe - isIntegerType verifies val <= math.MaxInt64 default: return 0 } @@ -77,34 +76,21 @@ func toInt64(v any) int64 { // toFloat64 converts a value to float64. // Strings are parsed as floats, matching Ruby Liquid's String#to_f behavior. +// Uses reflect.Kind so defined types (e.g. type MyFloat float32) are handled. func toFloat64(v any) float64 { - switch val := v.(type) { - case int: - return float64(val) - case int8: - return float64(val) - case int16: - return float64(val) - case int32: - return float64(val) - case int64: - return float64(val) - case uint: - return float64(val) - case uint8: - return float64(val) - case uint16: - return float64(val) - case uint32: - return float64(val) - case uint64: - return float64(val) - case float32: - return float64(val) - case float64: - return val - case string: - f, err := strconv.ParseFloat(val, 64) + if v == nil { + return 0 + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(rv.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return float64(rv.Uint()) + case reflect.Float32, reflect.Float64: + return rv.Float() + case reflect.String: + f, err := strconv.ParseFloat(rv.String(), 64) if err != nil { return 0 } @@ -117,8 +103,22 @@ func toFloat64(v any) float64 { // AddStandardFilters defines the standard Liquid filters. func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo // value filters - fd.AddFilter("default", func(value, defaultValue any) any { - if value == nil || value == false || values.IsEmpty(value) { + fd.AddFilter("default", func(value, defaultValue any, kwargs ...any) any { + allowFalse := false + for _, kw := range kwargs { + if na, ok := kw.(expressions.NamedArg); ok && na.Name == "allow_false" { + if b, ok := na.Value.(bool); ok { + allowFalse = b + } + } + } + isFalsy := value == nil || values.IsEmpty(value) + if !allowFalse { + // Use Truthy (reflect-based) so defined bool types (e.g. type MyBool bool) + // with a false value are also treated as falsy, matching plain bool semantics. + isFalsy = isFalsy || !values.Truthy(value) + } + if isFalsy { value = defaultValue } @@ -128,12 +128,23 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo result, _ := json.Marshal(a) return result }) + fd.AddFilter("jsonify", func(a any) any { + result, _ := json.Marshal(a) + return result + }) // array filters - fd.AddFilter("compact", func(a []any) (result []any) { + fd.AddFilter("compact", func(a []any, property func(string) string) (result []any) { + prop := property("") for _, item := range a { - if item != nil { - result = append(result, item) + if prop == "" { + if item != nil { + result = append(result, item) + } + } else { + if getPropertyValue(item, prop) != nil { + result = append(result, item) + } } } @@ -156,28 +167,118 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo fd.AddFilter("reverse", reverseFilter) fd.AddFilter("sort", sortFilter) // https://shopify.github.io/liquid/ does not demonstrate first and last as filters, - // but https://help.shopify.com/themes/liquid/filters/array-filters does - fd.AddFilter("first", func(a []any) any { - if len(a) == 0 { + // but https://help.shopify.com/themes/liquid/filters/array-filters does. + // Ruby and JS also support strings: first/last returns the first/last Unicode character. + fd.AddFilter("first", func(v any) any { + if s, ok := v.(string); ok { + if s == "" { + return "" + } + r, _ := utf8.DecodeRuneInString(s) + return string(r) + } + var slice []any + a, err := values.Convert(v, reflect.TypeOf(slice)) + if err != nil { return nil } - - return a[0] + arr, _ := a.([]any) + if len(arr) == 0 { + return nil + } + return arr[0] }) - fd.AddFilter("last", func(a []any) any { - if len(a) == 0 { + fd.AddFilter("last", func(v any) any { + if s, ok := v.(string); ok { + if s == "" { + return "" + } + runes := []rune(s) + return string(runes[len(runes)-1]) + } + var slice []any + a, err := values.Convert(v, reflect.TypeOf(slice)) + if err != nil { return nil } - - return a[len(a)-1] + arr, _ := a.([]any) + if len(arr) == 0 { + return nil + } + return arr[len(arr)-1] }) - fd.AddFilter("uniq", uniqFilter) + fd.AddFilter("uniq", func(a []any, property func(string) string) []any { + prop := property("") + if prop == "" { + return uniqFilter(a) + } + // property-based uniq: deduplicate by property value + seen := map[string]bool{} + var result []any + for _, item := range a { + pv := getPropertyValue(item, prop) + pvKey := fmt.Sprintf("%T|%v", pv, pv) + if !seen[pvKey] { + seen[pvKey] = true + result = append(result, item) + } + } + return result + }) + fd.AddFilter("where", whereFilter) + fd.AddFilter("reject", rejectFilter) + fd.AddFilter("group_by", groupByFilter) + fd.AddFilter("find", findFilter) + fd.AddFilter("find_index", findIndexFilter) + fd.AddFilter("has", hasFilter) + fd.AddFilter("sum", sumFilter) + fd.AddFilter("push", pushFilter) + fd.AddFilter("unshift", unshiftFilter) + fd.AddFilter("pop", popFilter) + fd.AddFilter("shift", shiftFilter) + fd.AddFilter("sample", sampleFilter) + + fd.AddContextFilter("where_exp", whereExpFilter) + fd.AddContextFilter("reject_exp", rejectExpFilter) + fd.AddContextFilter("group_by_exp", groupByExpFilter) + fd.AddContextFilter("find_exp", findExpFilter) + fd.AddContextFilter("find_index_exp", findIndexExpFilter) + fd.AddContextFilter("has_exp", hasExpFilter) // date filters - fd.AddFilter("date", func(t time.Time, format func(string) string) (string, error) { + fd.AddFilter("date", func(v any, format func(string) string) (any, error) { + if v == nil { + return nil, nil + } + t, ok := parseToTime(v) + if !ok { + return "", nil + } f := format("%a, %b %d, %y") return tuesday.Strftime(f, t) }) + fd.AddFilter("date_to_xmlschema", func(v any) string { + t, ok := parseToTime(v) + if !ok { + return fmt.Sprint(v) + } + result, _ := tuesday.Strftime("%Y-%m-%dT%H:%M:%S%:z", t) + return result + }) + fd.AddFilter("date_to_rfc822", func(v any) string { + t, ok := parseToTime(v) + if !ok { + return fmt.Sprint(v) + } + result, _ := tuesday.Strftime("%a, %d %b %Y %H:%M:%S %z", t) + return result + }) + fd.AddFilter("date_to_string", func(v any, typ func(string) string, style func(string) string) string { + return formatJekyllDate(v, "%b", typ(""), style("")) + }) + fd.AddFilter("date_to_long_string", func(v any, typ func(string) string, style func(string) string) string { + return formatJekyllDate(v, "%B", typ(""), style("")) + }) // number filters fd.AddFilter("abs", math.Abs) @@ -187,7 +288,104 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo fd.AddFilter("floor", func(a float64) int { return int(math.Floor(a)) }) - fd.AddFilter("modulo", math.Mod) + fd.AddFilter("modulo", func(rawA, b any) (any, error) { + // modulo semantics (Ruby/Shopify Liquid compatible): + // • Both operands are integer types → floored integer modulo. + // • Either operand is a float or string → floored float modulo. + // Ruby's % operator uses floor modulo (result has same sign as divisor). + // Go's % operator and math.Mod use truncated modulo (same sign as dividend), + // so we adjust the result when the signs differ. + modInt := func(a, b int64) (int64, error) { + if b == 0 { + return 0, &ZeroDivisionError{} + } + result := a % b + // floor modulo: adjust sign to match divisor + if result != 0 && (result > 0) != (b > 0) { + result += b + } + return result, nil + } + modFloat := func(a, b float64) (float64, error) { + if b == 0 { + return 0, &ZeroDivisionError{} + } + result := math.Mod(a, b) + // floor modulo: adjust sign to match divisor + if result != 0 && math.Signbit(result) != math.Signbit(b) { + result += b + } + return result, nil + } + + aIsInt := isIntegerType(rawA) + + switch q := b.(type) { + case int: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case int8: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case int16: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case int32: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case int64: + if aIsInt { + return modInt(toInt64(rawA), q) + } + return modFloat(toFloat64(rawA), float64(q)) + case uint8: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case uint16: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case uint32: + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case uint: //nolint:gosec // G115: safe for values <= math.MaxInt64 + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case uint64: //nolint:gosec // G115: safe for values <= math.MaxInt64 + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case uintptr: //nolint:gosec // G115: safe for values <= math.MaxInt64 + if aIsInt { + return modInt(toInt64(rawA), int64(q)) + } + return modFloat(toFloat64(rawA), float64(q)) + case float32: + return modFloat(toFloat64(rawA), float64(q)) + case float64: + return modFloat(toFloat64(rawA), q) + case string: + return modFloat(toFloat64(rawA), toFloat64(q)) + default: + return nil, fmt.Errorf("invalid modulus: '%v'", b) + } + }) fd.AddFilter("minus", func(a, b any) any { // If both operands are integers, perform integer arithmetic if isIntegerType(a) && isIntegerType(b) { @@ -212,10 +410,18 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo // Otherwise, perform float arithmetic return toFloat64(a) * toFloat64(b) }) - fd.AddFilter("divided_by", func(a float64, b any) (any, error) { + fd.AddFilter("divided_by", func(rawA, b any) (any, error) { + // divided_by semantics (Ruby/Shopify Liquid compatible): + // • Both operands are integer types → floor (integer) division. + // • Either operand is a float → float division. + // The filter parameter rawA is declared as `any` so that we can + // distinguish between integer literals (e.g. `2`) and float literals + // (e.g. `2.0`). When registered as `func(float64, any)` the original + // int vs. float distinction was lost because the conversion always + // produced float64. divInt := func(a, b int64) (int64, error) { if b == 0 { - return 0, errDivisionByZero + return 0, &ZeroDivisionError{} } return a / b, nil @@ -223,32 +429,74 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo divFloat := func(a, b float64) (float64, error) { if b == 0 { - return 0, errDivisionByZero + return 0, &ZeroDivisionError{} } return a / b, nil } + + aIsInt := isIntegerType(rawA) + switch q := b.(type) { case int: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case int8: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case int16: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case int32: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case int64: - return divInt(int64(a), q) + if aIsInt { + return divInt(toInt64(rawA), q) + } + return divFloat(toFloat64(rawA), float64(q)) case uint8: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case uint16: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case uint32: - return divInt(int64(a), int64(q)) + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) + case uint: //nolint:gosec // G115: safe for values <= math.MaxInt64 + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) + case uint64: //nolint:gosec // G115: safe for values <= math.MaxInt64 + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) + case uintptr: //nolint:gosec // G115: safe for values <= math.MaxInt64 + if aIsInt { + return divInt(toInt64(rawA), int64(q)) + } + return divFloat(toFloat64(rawA), float64(q)) case float32: - return divFloat(a, float64(q)) + return divFloat(toFloat64(rawA), float64(q)) case float64: - return divFloat(a, q) + return divFloat(toFloat64(rawA), q) default: return nil, fmt.Errorf("invalid divisor: '%v'", b) } @@ -267,22 +515,27 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo fd.AddFilter("append", func(s, suffix string) string { return s + suffix }) - fd.AddFilter("capitalize", func(s, suffix string) string { - if len(s) == 0 { + fd.AddFilter("capitalize", func(s string) string { + if s == "" { return s } - - return strings.ToUpper(s[:1]) + s[1:] + r, size := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + strings.ToLower(s[size:]) }) fd.AddFilter("downcase", func(s, suffix string) string { return strings.ToLower(s) }) fd.AddFilter("escape", html.EscapeString) + fd.AddFilter("h", html.EscapeString) fd.AddFilter("escape_once", func(s, suffix string) string { return html.EscapeString(html.UnescapeString(s)) }) fd.AddFilter("newline_to_br", func(s string) string { - return strings.ReplaceAll(s, "\n", "
") + // Normalize Windows line endings (\r\n) to Unix (\n) first, + // then convert all \n to
\n — matching Ruby/JS behaviour. + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return strings.ReplaceAll(s, "\n", "
\n") }) fd.AddFilter("prepend", func(s, prefix string) string { return prefix + s @@ -308,6 +561,9 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo // Work on runes, not chars runes := []rune(s) n := length(1) + if n < 0 { + n = 0 + } if start < 0 { start = len(runes) + start if start < 0 { @@ -349,39 +605,68 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo return nil }) fd.AddFilter("split", splitFilter) - fd.AddFilter("strip_html", func(s string) string { - // TODO this probably isn't sufficient - return regexp.MustCompile(`<.*?>`).ReplaceAllString(s, "") - }) + fd.AddFilter("strip_html", stripHTMLFilter) fd.AddFilter("strip_newlines", func(s string) string { + // Remove \r\n (Windows), \r (old Mac), and \n (Unix) — matching Ruby/JS. + s = strings.ReplaceAll(s, "\r\n", "") + s = strings.ReplaceAll(s, "\r", "") return strings.ReplaceAll(s, "\n", "") }) - fd.AddFilter("strip", strings.TrimSpace) - fd.AddFilter("lstrip", func(s string) string { + fd.AddFilter("strip", func(s string, chars func(string) string) string { + if c := chars(""); c != "" { + return strings.Trim(s, c) + } + return strings.TrimSpace(s) + }) + fd.AddFilter("lstrip", func(s string, chars func(string) string) string { + if c := chars(""); c != "" { + return strings.TrimLeft(s, c) + } return strings.TrimLeftFunc(s, unicode.IsSpace) }) - fd.AddFilter("rstrip", func(s string) string { + fd.AddFilter("rstrip", func(s string, chars func(string) string) string { + if c := chars(""); c != "" { + return strings.TrimRight(s, c) + } return strings.TrimRightFunc(s, unicode.IsSpace) }) + fd.AddFilter("squish", func(s string) string { + return strings.TrimSpace(wsre.ReplaceAllString(s, " ")) + }) fd.AddFilter("truncate", func(s string, length func(int) int, ellipsis func(string) string) string { n := length(50) el := ellipsis("...") - // runes aren't bytes; don't use slice - re := regexp.MustCompile(fmt.Sprintf(`^(.{%d})..{%d,}`, n-len(el), len(el))) - - return re.ReplaceAllString(s, `$1`+el) + // Ruby/JS: if n <= len(el), return the full ellipsis (e.g. truncate: 0 => "..."). + erunes := []rune(el) + if n <= len(erunes) { + return el + } + // If the string already fits within the limit, return it unchanged. + srunes := []rune(s) + if len(srunes) <= n { + return s + } + // Take first (n - len(el)) runes, then append ellipsis. + return string(srunes[:n-len(erunes)]) + el }) fd.AddFilter("truncatewords", func(s string, length func(int) int, ellipsis func(string) string) string { el := ellipsis("...") n := length(15) - re := regexp.MustCompile(fmt.Sprintf(`^(?:\s*\S+){%d}`, n)) - - m := re.FindString(s) - if m == "" { + // n < 1 behaves like n = 1 (Ruby/JS: truncate to 1 word) + if n < 1 { + n = 1 + } + // Count words first: if the string has <= n words, return it unchanged. + // We cannot rely solely on the regex because Go's RE2 allows backtracking + // across word boundaries (e.g. {4} on "one two three" still matches via + // splitting the last word), giving false positives. + words := strings.Fields(s) + if len(words) <= n { return s } - - return m + el + // There are more than n words: join the first n words with single spaces + // (matches Ruby behaviour which normalises internal whitespace). + return strings.Join(words[:n], " ") + el }) fd.AddFilter("upcase", func(s, suffix string) string { return strings.ToUpper(s) @@ -389,6 +674,191 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo fd.AddFilter("url_encode", url.QueryEscape) fd.AddFilter("url_decode", url.QueryUnescape) + // string filters + fd.AddFilter("remove_last", func(s, sub string) string { + idx := strings.LastIndex(s, sub) + if idx < 0 { + return s + } + + return s[:idx] + s[idx+len(sub):] + }) + fd.AddFilter("replace_last", func(s, old, new string) string { + idx := strings.LastIndex(s, old) + if idx < 0 { + return s + } + + return s[:idx] + new + s[idx+len(old):] + }) + fd.AddFilter("normalize_whitespace", func(s string) string { + return wsre.ReplaceAllString(s, " ") + }) + fd.AddFilter("number_of_words", func(s string, mode func(string) string) int { + m := mode("default") + switch m { + case "cjk": + return countWordsWithCJK(s) + case "auto": + for _, r := range s { + if isCJKRune(r) { + return countWordsWithCJK(s) + } + } + + return len(strings.Fields(s)) + default: + return len(strings.Fields(s)) + } + }) + fd.AddFilter("array_to_sentence_string", func(a []any, connector func(string) string) string { + con := connector("and") + strs := make([]string, len(a)) + for i, v := range a { + strs[i] = fmt.Sprint(v) + } + + switch len(strs) { + case 0: + return "" + case 1: + return strs[0] + case 2: + return strs[0] + " " + con + " " + strs[1] + default: + return strings.Join(strs[:len(strs)-1], ", ") + ", " + con + " " + strs[len(strs)-1] + } + }) + + // math filters + fd.AddFilter("at_least", func(a, b float64) float64 { + return math.Max(a, b) + }) + fd.AddFilter("at_most", func(a, b float64) float64 { + return math.Min(a, b) + }) + + // html/url filters + // raw marks a value as safe, bypassing autoescape. Mirrors LiquidJS's | raw filter. + // When autoescape is disabled (the default), raw wraps in SafeValue, which + // is immediately transparent at render time — effectively a no-op. + fd.AddFilter("raw", func(v any) values.SafeValue { + return values.SafeValue{Value: v} + }) + fd.AddFilter("xml_escape", func(s string) string { + var buf strings.Builder + for _, r := range s { + switch r { + case '&': + buf.WriteString("&") + case '<': + buf.WriteString("<") + case '>': + buf.WriteString(">") + case '"': + buf.WriteString(""") + case '\'': + buf.WriteString("'") + default: + buf.WriteRune(r) + } + } + + return buf.String() + }) + fd.AddFilter("cgi_escape", url.QueryEscape) + fd.AddFilter("uri_escape", func(s string) string { + var buf strings.Builder + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if isURISafe(r) { + buf.WriteRune(r) + } else { + for _, b := range []byte(s[i : i+size]) { + fmt.Fprintf(&buf, "%%%02X", b) + } + } + i += size + } + + return buf.String() + }) + fd.AddFilter("slugify", func(s string, mode func(string) string) string { + return slugifyString(s, mode("default")) + }) + + // base64 filters + fd.AddFilter("base64_encode", func(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) + }) + fd.AddFilter("base64_decode", func(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", err + } + + return string(b), nil + }) + fd.AddFilter("base64_url_safe_encode", func(s string) string { + return base64.URLEncoding.EncodeToString([]byte(s)) + }) + fd.AddFilter("base64_url_safe_decode", func(s string) (string, error) { + b, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return "", err + } + + return string(b), nil + }) + + // type conversion filters + fd.AddFilter("to_integer", func(v any) int { + switch val := v.(type) { + case int: + return val + case int8: + return int(val) + case int16: + return int(val) + case int32: + return int(val) + case int64: + return int(val) + case uint: + return int(val) + case uint8: + return int(val) + case uint16: + return int(val) + case uint32: + return int(val) + case uint64: + return int(val) + case float32: + return int(val) + case float64: + return int(val) + case string: + trimmed := strings.TrimSpace(val) + if i, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + return int(i) + } + if f, err := strconv.ParseFloat(trimmed, 64); err == nil { + return int(f) + } + + return 0 + case bool: + if val { + return 1 + } + + return 0 + default: + return 0 + } + }) + // debugging filters // inspect is from Jekyll fd.AddFilter("inspect", func(value any) string { @@ -428,6 +898,19 @@ func reverseFilter(a []any) any { var wsre = regexp.MustCompile(`[[:space:]]+`) +var ( + stripHTMLScriptStyleRe = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?`) + stripHTMLCommentRe = regexp.MustCompile(`(?s)`) + stripHTMLTagRe = regexp.MustCompile(`<[^>]*>`) +) + +func stripHTMLFilter(s string) string { + s = stripHTMLScriptStyleRe.ReplaceAllString(s, "") + s = stripHTMLCommentRe.ReplaceAllString(s, "") + s = stripHTMLTagRe.ReplaceAllString(s, "") + return s +} + func splitFilter(s, sep string) any { result := strings.Split(s, sep) if sep == " " { @@ -480,3 +963,177 @@ func eqItems(a, b any) bool { return reflect.DeepEqual(a, b) } + +// isCJKRune reports whether r is a CJK (Chinese, Japanese, Korean) character. +func isCJKRune(r rune) bool { + return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs + (r >= 0x3400 && r <= 0x4DBF) || // CJK Extension A + (r >= 0x20000 && r <= 0x2A6DF) || // CJK Extension B + (r >= 0xAC00 && r <= 0xD7AF) || // Hangul + (r >= 0x3040 && r <= 0x309F) || // Hiragana + (r >= 0x30A0 && r <= 0x30FF) // Katakana +} + +// countWordsWithCJK counts words treating each CJK character as an individual word. +func countWordsWithCJK(s string) int { + count := 0 + inWord := false + + for _, r := range s { + if isCJKRune(r) { + if inWord { + count++ + inWord = false + } + count++ + } else if unicode.IsSpace(r) { + if inWord { + count++ + inWord = false + } + } else { + inWord = true + } + } + + if inWord { + count++ + } + + return count +} + +// isURISafe reports whether r should not be percent-encoded in a URI. +// Matches the behavior of JavaScript's encodeURI(). +func isURISafe(r rune) bool { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return true + } + + switch r { + case '-', '_', '.', '!', '~', '*', '\'', '(', ')', + ';', ',', '/', '?', ':', '@', '&', '=', '+', '$', '#', '[', ']': + return true + } + + return false +} + +var ( + slugifyDefaultRe = regexp.MustCompile(`[^\p{L}\p{N}\-]+`) + slugifyASCIIRe = regexp.MustCompile(`[^a-z0-9\-]+`) + slugifyPrettyRe = regexp.MustCompile(`[^\p{L}\p{N}._~!$&'()*+,;=:@/\-]+`) + slugifyMultiHyphRe = regexp.MustCompile(`-{2,}`) + slugifyTrimHyphRe = regexp.MustCompile(`^-+|-+$`) +) + +// latinAccentReplacer maps common accented latin characters to their ASCII equivalents. +var latinAccentReplacer = strings.NewReplacer( + "à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", + "è", "e", "é", "e", "ê", "e", "ë", "e", + "ì", "i", "í", "i", "î", "i", "ï", "i", + "ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", + "ù", "u", "ú", "u", "û", "u", "ü", "u", + "ý", "y", "ÿ", "y", + "ñ", "n", "ç", "c", "ß", "ss", + "À", "a", "Á", "a", "Â", "a", "Ã", "a", "Ä", "a", "Å", "a", + "È", "e", "É", "e", "Ê", "e", "Ë", "e", + "Ì", "i", "Í", "i", "Î", "i", "Ï", "i", + "Ò", "o", "Ó", "o", "Ô", "o", "Õ", "o", "Ö", "o", "Ø", "o", + "Ù", "u", "Ú", "u", "Û", "u", "Ü", "u", + "Ý", "y", "Ñ", "n", "Ç", "c", +) + +// parseToTime converts a Liquid date value (string, time.Time, or int64 unix +// timestamp) to time.Time. Returns (t, true) on success, (zero, false) on failure. +func parseToTime(v any) (time.Time, bool) { + switch t := v.(type) { + case time.Time: + return t, true + case string: + parsed, err := values.ParseDate(t) + if err != nil { + return time.Time{}, false + } + return parsed, true + default: + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return time.Unix(rv.Int(), 0), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return time.Unix(int64(rv.Uint()), 0), true + case reflect.Float32, reflect.Float64: + return time.Unix(int64(rv.Float()), 0), true + default: + return time.Time{}, false + } + } +} + +// ordinalSuffix returns the ordinal suffix for a day number (1→"st", 2→"nd", etc.). +func ordinalSuffix(n int) string { + switch { + case n >= 11 && n <= 13: + return "th" + case n%10 == 1: + return "st" + case n%10 == 2: + return "nd" + case n%10 == 3: + return "rd" + default: + return "th" + } +} + +// formatJekyllDate formats a date in Jekyll's date_to_string / date_to_long_string style. +// monthFmt is the strftime token for the month (%b abbreviated, %B full). +// typeArg is "" for the default DD Mon YYYY format or "ordinal" for ordinal day. +// styleArg is "" (UK: 7th Nov 2008) or "US" (Nov 7th, 2008). +func formatJekyllDate(v any, monthFmt, typeArg, styleArg string) string { + t, ok := parseToTime(v) + if !ok { + return fmt.Sprint(v) + } + if typeArg == "ordinal" { + day := t.Day() + suffix := ordinalSuffix(day) + month, _ := tuesday.Strftime(monthFmt, t) + year := t.Year() + if styleArg == "US" { + return fmt.Sprintf("%s %d%s, %d", month, day, suffix, year) + } + return fmt.Sprintf("%d%s %s %d", day, suffix, month, year) + } + result, _ := tuesday.Strftime("%d "+monthFmt+" %Y", t) + return result +} + +// slugifyString normalizes a string to a URL slug according to the given mode. +// Modes: "default" (unicode-aware), "ascii", "latin" (transliterate accents), +// "pretty" (preserve common URL chars), "none"/"raw" (lowercase only). +// Unknown modes fall back to lowercase-only, matching LiquidJS behavior. +func slugifyString(s, mode string) string { + applyHyphens := func(s string, re *regexp.Regexp) string { + s = re.ReplaceAllString(s, "-") + s = slugifyTrimHyphRe.ReplaceAllString(s, "") + s = slugifyMultiHyphRe.ReplaceAllString(s, "-") + + return s + } + + switch mode { + case "default": + return applyHyphens(strings.ToLower(s), slugifyDefaultRe) + case "ascii": + return applyHyphens(strings.ToLower(s), slugifyASCIIRe) + case "latin": + return applyHyphens(strings.ToLower(latinAccentReplacer.Replace(s)), slugifyASCIIRe) + case "pretty": + return applyHyphens(strings.ToLower(s), slugifyPrettyRe) + default: + // "none", "raw", and any unknown mode: lowercase only, no char replacement. + return strings.ToLower(s) + } +} diff --git a/filters/standard_filters_test.go b/filters/standard_filters_test.go index a32eb66e..212fcb63 100644 --- a/filters/standard_filters_test.go +++ b/filters/standard_filters_test.go @@ -1,7 +1,9 @@ package filters import ( + "errors" "fmt" + "regexp" "strings" "testing" "time" @@ -29,6 +31,14 @@ var filterTests = []struct { {`"true" | default: 2.99`, "true"}, {`4.99 | default: 2.99`, 4.99}, {`fruits | default: 2.99 | join`, "apples oranges peaches plums"}, + + // default filter — allow_false: true keyword arg [ruby: standardfilters_test.rb; js: misc.spec.ts] + {`false | default: 2.99, allow_false: true`, false}, + {`nil | default: 2.99, allow_false: true`, 2.99}, + {`"" | default: 2.99, allow_false: true`, 2.99}, + {`empty_array | default: 2.99, allow_false: true`, 2.99}, + {`true | default: 2.99, allow_false: true`, true}, + {`4.99 | default: 2.99, allow_false: true`, 4.99}, {`"string" | json`, "\"string\""}, {`true | json`, "true"}, {`1 | json`, "1"}, @@ -41,7 +51,7 @@ var filterTests = []struct { {`",John, Paul, George, Ringo" | split: ", " | join: " and "`, ",John and Paul and George and Ringo"}, {`"John, Paul, George, Ringo," | split: ", " | join: " and "`, "John and Paul and George and Ringo,"}, {`animals | sort | join: ", "`, "Sally Snake, giraffe, octopus, zebra"}, - {`sort_prop | sort: "weight" | inspect`, `[{"weight":null},{"weight":1},{"weight":3},{"weight":5}]`}, + {`sort_prop | sort: "weight" | inspect`, `[{"weight":1},{"weight":3},{"weight":5},{"weight":null}]`}, {`fruits | reverse | join: ", "`, "plums, peaches, oranges, apples"}, {`fruits | first`, "apples"}, {`fruits | last`, "plums"}, @@ -82,6 +92,16 @@ var filterTests = []struct { {`"2017-07-09" | date: "%e/%m"`, " 9/07"}, {`"2017-07-09" | date: "%-d/%-m"`, "9/7"}, + // date_to_string filter [js: test/integration/filters/date.spec.ts] + {`"2008-11-07T13:07:54-08:00" | date_to_string`, "07 Nov 2008"}, + {`"2008-11-07T13:07:54-08:00" | date_to_string: "ordinal", "US"`, "Nov 7th, 2008"}, + {`"hello" | date_to_string: "ordinal", "US"`, "hello"}, + + // date_to_long_string filter [js: test/integration/filters/date.spec.ts] + {`"2008-11-07T13:07:54-08:00" | date_to_long_string`, "07 November 2008"}, + {`"2008-11-07T13:07:54-08:00" | date_to_long_string: "ordinal", "US"`, "November 7th, 2008"}, + {`"2008-11-07T13:07:54-08:00" | date_to_long_string: "ordinal"`, "7th November 2008"}, + // sequence (array or string) filters {`"Ground control to Major Tom." | size`, 28}, {`"apples, oranges, peaches, plums" | split: ", " | size`, 4}, @@ -95,11 +115,12 @@ var filterTests = []struct { {`"website.com" | append: "/index.html"`, "website.com/index.html"}, {`"title" | capitalize`, "Title"}, {`"my great title" | capitalize`, "My great title"}, + {`"MY GREAT TITLE" | capitalize`, "My great title"}, {`"" | capitalize`, ""}, {`"Parker Moore" | downcase`, "parker moore"}, {`"Have you read 'James & the Giant Peach'?" | escape`, "Have you read 'James & the Giant Peach'?"}, {`"1 < 2 & 3" | escape_once`, "1 < 2 & 3"}, - {`string_with_newlines | newline_to_br`, "
Hello
there
"}, + {`string_with_newlines | newline_to_br`, "
\nHello
\nthere
\n"}, {`"1 < 2 & 3" | escape_once`, "1 < 2 & 3"}, {`"apples, oranges, and bananas" | prepend: "Some fruit: "`, "Some fruit: apples, oranges, and bananas"}, {`"I strained to see the train through the rain" | remove: "rain"`, "I sted to see the t through the "}, @@ -150,6 +171,13 @@ Liquid" | slice: 2, 4`, "quid"}, {"'a \t b' | split: ' ' | join: '-'", "a-b"}, {`"Have you read Ulysses?" | strip_html`, "Have you read Ulysses?"}, + // strip_html: script and style blocks (with content) are removed [ruby: standard_filter_test.rb] + {`"Hello" | strip_html`, "Hello"}, + {`"World" | strip_html`, "World"}, + {`"Content" | strip_html`, "Content"}, + // strip_html: HTML comments are removed [ruby: standard_filter_test.rb] + {`"Hello" | strip_html`, "Hello"}, + {`"ABC" | strip_html`, "ABC"}, {`string_with_newlines | strip_newlines`, "Hellothere"}, {`"Ground control to Major Tom." | truncate: 20`, "Ground control to..."}, @@ -169,11 +197,152 @@ Liquid" | slice: 2, 4`, "quid"}, {`" So much room for activities! " | strip`, "So much room for activities!"}, {`" So much room for activities! " | lstrip`, "So much room for activities! "}, {`" So much room for activities! " | rstrip`, " So much room for activities!"}, + // strip/lstrip/rstrip with chars argument [liquidjs] + {`"abcHello Worldabc" | strip: "abc"`, "Hello World"}, + {`"abcHello World" | lstrip: "abc"`, "Hello World"}, + {`"Hello Worldabc" | rstrip: "abc"`, "Hello World"}, + // squish: strip + collapse internal whitespace [ruby] + {`" Hello World " | squish`, "Hello World"}, + {"\" Hello\\n\\tWorld \" | squish", "Hello World"}, + // h: alias for escape [ruby] + {`"Have you read 'James & the Giant Peach'?" | h`, "Have you read 'James & the Giant Peach'?"}, {`"%27Stop%21%27+said+Fred" | url_decode`, "'Stop!' said Fred"}, {`"john@liquid.com" | url_encode`, "john%40liquid.com"}, {`"Tetsuro Takara" | url_encode`, "Tetsuro+Takara"}, + // string filters + {`"I strained to see the train through the rain" | remove_last: "rain"`, "I strained to see the train through the "}, + {`"hello world" | remove_last: "l"`, "hello word"}, + {`"no match" | remove_last: "xyz"`, "no match"}, + {`"Take my protein pills and put my helmet on" | replace_last: "my", "your"`, "Take my protein pills and put your helmet on"}, + {`"hello world" | replace_last: "l", "L"`, "hello worLd"}, + {`"no match" | replace_last: "xyz", "abc"`, "no match"}, + {`" hello world " | normalize_whitespace`, " hello world "}, + {"\"hello\nworld\ttab\" | normalize_whitespace", "hello world tab"}, + {`"one two three" | number_of_words`, 3}, + {`"" | number_of_words`, 0}, + {`" " | number_of_words`, 0}, + {`"Hello world!" | number_of_words`, 2}, + {`"你好hello世界world" | number_of_words`, 1}, + {`" Hello world! " | number_of_words`, 2}, + {`"hello world" | number_of_words: "cjk"`, 2}, + {`"你好hello世界world" | number_of_words: "cjk"`, 6}, + {`"" | number_of_words: "cjk"`, 0}, + {`"你好こんにちは안녕하세요" | number_of_words: "cjk"`, 12}, + {`"hello 日本語 world" | number_of_words: "auto"`, 5}, + {`"hello world" | number_of_words: "auto"`, 2}, + {`"你好hello世界world" | number_of_words: "auto"`, 6}, + {`"你好世界" | number_of_words: "auto"`, 4}, + {`fruits | array_to_sentence_string`, "apples, oranges, peaches, and plums"}, + {`"a,b" | split: "," | array_to_sentence_string`, "a and b"}, + {`"a" | split: "," | array_to_sentence_string`, "a"}, + {`"a,b,c" | split: "," | array_to_sentence_string: "or"`, "a, b, or c"}, + + // where filter [ruby: standard_filter_test.rb] + {`where_array | where: "ok" | map: "handle" | join: " "`, "alpha delta"}, + {`where_array | where: "ok", true | map: "handle" | join: " "`, "alpha delta"}, + {`where_array | where: "ok", false | map: "handle" | join: " "`, "beta gamma"}, + {`where_messages | where: "language", "French" | map: "message" | join`, "Bonjour!"}, + {`where_messages | where: "language", "German" | map: "message" | join`, "Hallo!"}, + {`where_messages | where: "language", "English" | map: "message" | join`, "Hello!"}, + {`where_truthy | where: "foo" | map: "foo" | join: " "`, "true for sure"}, + + // reject filter [ruby: standard_filter_test.rb] + {`where_array | reject: "ok" | map: "handle" | join: " "`, "beta gamma"}, + {`where_array | reject: "ok", true | map: "handle" | join: " "`, "beta gamma"}, + {`where_array | reject: "ok", false | map: "handle" | join: " "`, "alpha delta"}, + + // group_by filter [liquidjs: test/integration/filters/array.spec.ts] + {`group_members | group_by: "graduation_year" | map: "name" | join: ", "`, "2003, 2014, 2004"}, + + // find filter [ruby: standard_filter_test.rb] + {`find_products | find: "price", 3999 | inspect`, `{"price":3999,"title":"Alpine jacket"}`}, + // find filter [liquidjs: test/integration/filters/array.spec.ts] + {`group_members | find: "graduation_year", 2014 | inspect`, `{"graduation_year":2014,"name":"John"}`}, + {`find_products | find: "price", 9999`, nil}, + + // find_index filter [ruby: standard_filter_test.rb] + {`find_products | find_index: "price", 3999`, 2}, + // find_index filter [liquidjs: test/integration/filters/array.spec.ts] + {`group_members | find_index: "graduation_year", 2014`, 2}, + {`group_members | find_index: "graduation_year", 2018`, nil}, + + // has filter [ruby: standard_filter_test.rb] + {`has_array_truthy | has: "ok"`, true}, + {`has_array_truthy | has: "ok", true`, true}, + {`has_array_falsy | has: "ok"`, false}, + {`has_array_truthy | has: "ok", false`, true}, + {`has_array_all_true | has: "ok", false`, false}, + // has filter [liquidjs: test/integration/filters/array.spec.ts] + {`group_members | has: "graduation_year", 2014`, true}, + {`group_members | has: "graduation_year", 2018`, false}, + + // sum filter [ruby: standard_filter_test.rb] + {`sum_ints | sum`, int64(3)}, + {`sum_mixed | sum`, int64(10)}, + {`sum_objects | sum: "quantity"`, int64(3)}, + {`sum_objects | sum: "weight"`, int64(7)}, + {`sum_objects | sum: "subtotal"`, int64(0)}, + {`sum_floats | sum`, 0.6000000000000001}, + {`sum_neg_floats | sum`, -0.4}, + + // push filter [liquidjs: test/integration/filters/array.spec.ts] + {`fruits | push: "grapes" | join: ", "`, "apples, oranges, peaches, plums, grapes"}, + {`fruits | push: "grapes" | size`, 5}, + + // unshift filter [liquidjs: test/integration/filters/array.spec.ts] + {`fruits | unshift: "grapes" | join: ", "`, "grapes, apples, oranges, peaches, plums"}, + {`fruits | unshift: "grapes" | size`, 5}, + + // pop filter [liquidjs: test/integration/filters/array.spec.ts] + {`fruits | pop | join: ", "`, "apples, oranges, peaches"}, + {`empty_array | pop | size`, 0}, + + // shift filter [liquidjs: test/integration/filters/array.spec.ts] + {`fruits | shift | join: ", "`, "oranges, peaches, plums"}, + {`empty_array | shift | size`, 0}, + + // math filters + {`4 | at_least: 5`, 5.0}, + {`4 | at_least: 3`, 4.0}, + {`4 | at_most: 5`, 4.0}, + {`4 | at_most: 3`, 3.0}, + + // html/url filters + {`"Have you read 'James & the Giant Peach'?" | xml_escape`, "Have you read 'James & the Giant Peach'?"}, + {`'' | xml_escape`, "<script>"alert"</script>"}, + {`"john@liquid.com" | cgi_escape`, "john%40liquid.com"}, + {`"hello world" | cgi_escape`, "hello+world"}, + {`"foo, bar; baz?" | cgi_escape`, "foo%2C+bar%3B+baz%3F"}, + {`"hello world" | uri_escape`, "hello%20world"}, + {`"http://example.com/?q=foo, \bar?" | uri_escape`, "http://example.com/?q=foo,%20%5Cbar?"}, + {`"!#$&'()*+,/:;=?@[]" | uri_escape`, "!#$&'()*+,/:;=?@[]"}, + {`"Hello World" | slugify`, "hello-world"}, + {`"The _config.yml file" | slugify`, "the-config-yml-file"}, + {`"The _config.yml file" | slugify: "pretty"`, "the-_config.yml-file"}, + {`"The _cönfig.yml file" | slugify: "ascii"`, "the-c-nfig-yml-file"}, + {`"The cönfig.yml file" | slugify: "latin"`, "the-config-yml-file"}, + {`"The _config.yml file" | slugify: "none"`, "the _config.yml file"}, + {`"The _config.yml file" | slugify: "raw"`, "the _config.yml file"}, + {`"Hello World" | slugify: "invalid_mode"`, "hello world"}, + + // base64 filters [ruby: standard_filter_test.rb] + {`"hello" | base64_encode`, "aGVsbG8="}, + {`"aGVsbG8=" | base64_decode`, "hello"}, + {`"hello" | base64_url_safe_encode`, "aGVsbG8="}, + {`"aGVsbG8=" | base64_url_safe_decode`, "hello"}, + // base64 url-safe uses - and _ instead of + and / + {`"Man" | base64_url_safe_encode`, "TWFu"}, + {`"TWFu" | base64_url_safe_decode`, "Man"}, + + // type conversion filters + {`"3.5" | to_integer`, 3}, + {`3.9 | to_integer`, 3}, + {`"42" | to_integer`, 42}, + {`true | to_integer`, 1}, + {`false | to_integer`, 0}, + // number filters {`-17 | abs`, 17.0}, {`4 | abs`, 4.0}, @@ -228,8 +397,8 @@ Liquid" | slice: 2, 4`, "quid"}, {`str_int | plus: 1`, 11.0}, {`str_float | plus: 1.0`, 4.5}, - {`3 | modulo: 2`, 1.0}, - {`24 | modulo: 7`, 3.0}, + {`3 | modulo: 2`, int64(1)}, + {`24 | modulo: 7`, int64(3)}, // {`183.357 | modulo: 12 | `, 3.357}, // TODO test suit use inexact {`16 | divided_by: 4`, int64(4)}, @@ -246,6 +415,25 @@ Liquid" | slice: 2, 4`, "quid"}, {`map | inspect`, `{"a":1}`}, {`1 | type`, `int`}, {`"1" | type`, `string`}, + + // jsonify: alias for json [liquidjs] + {`"string" | jsonify`, "\"string\""}, + {`true | jsonify`, "true"}, + {`1 | jsonify`, "1"}, + + // default: allow_false keyword arg [ruby: standard_filter_test.rb, liquidjs] + {`false | default: 2.99, allow_false: true`, false}, + {`nil | default: 2.99, allow_false: true`, 2.99}, + {`"" | default: 2.99, allow_false: true`, 2.99}, + + // compact with property argument [ruby: standard_filter_test.rb] + {`compact_with_nil_prop | compact: "prop" | map: "name" | join`, `a b`}, + + // uniq with property argument [ruby: standard_filter_test.rb] + {`dup_prop_objects | uniq: "name" | map: "name" | join`, `a b`}, + + // sort nil-last [ruby: standard_filter_test.rb] + {`sort_prop | sort: "weight" | map: "weight" | last`, nil}, } var filterErrorTests = []struct { @@ -253,7 +441,9 @@ var filterErrorTests = []struct { error string }{ {`20 | divided_by: 's'`, `error applying filter "divided_by" ("invalid divisor: 's'")`}, - {`20 | divided_by: 0`, `error applying filter "divided_by" ("division by zero")`}, + {`20 | divided_by: 0`, `error applying filter "divided_by" ("divided by 0")`}, + {`"not-base64!!!" | base64_decode`, `error applying filter "base64_decode" ("illegal base64 data at input byte 3")`}, + {`"not-base64" | base64_url_safe_decode`, `error applying filter "base64_url_safe_decode" ("illegal base64 data at input byte 8")`}, } var filterTestBindings = map[string]any{ @@ -327,6 +517,80 @@ var filterTestBindings = map[string]any{ {Str: "b"}, {Str: "c"}, }, + // where filter test data + "where_array": []any{ + map[string]any{"handle": "alpha", "ok": true}, + map[string]any{"handle": "beta", "ok": false}, + map[string]any{"handle": "gamma", "ok": false}, + map[string]any{"handle": "delta", "ok": true}, + }, + "where_messages": []any{ + map[string]any{"message": "Bonjour!", "language": "French"}, + map[string]any{"message": "Hello!", "language": "English"}, + map[string]any{"message": "Hallo!", "language": "German"}, + }, + "where_truthy": []any{ + map[string]any{"foo": false}, + map[string]any{"foo": true}, + map[string]any{"foo": "for sure"}, + map[string]any{"bar": true}, + }, + // has filter test data + "has_array_truthy": []any{ + map[string]any{"handle": "alpha", "ok": true}, + map[string]any{"handle": "beta", "ok": false}, + map[string]any{"handle": "gamma", "ok": false}, + map[string]any{"handle": "delta", "ok": false}, + }, + "has_array_falsy": []any{ + map[string]any{"handle": "alpha", "ok": false}, + map[string]any{"handle": "beta", "ok": false}, + map[string]any{"handle": "gamma", "ok": false}, + map[string]any{"handle": "delta", "ok": false}, + }, + "has_array_all_true": []any{ + map[string]any{"handle": "alpha", "ok": true}, + map[string]any{"handle": "beta", "ok": true}, + map[string]any{"handle": "gamma", "ok": true}, + map[string]any{"handle": "delta", "ok": true}, + }, + // group_by / find filter test data + "group_members": []any{ + map[string]any{"graduation_year": 2003, "name": "Jay"}, + map[string]any{"graduation_year": 2003, "name": "John"}, + map[string]any{"graduation_year": 2014, "name": "John"}, + map[string]any{"graduation_year": 2004, "name": "Jack"}, + }, + // find filter test data + "find_products": []any{ + map[string]any{"title": "Pro goggles", "price": 1299}, + map[string]any{"title": "Thermal gloves", "price": 1499}, + map[string]any{"title": "Alpine jacket", "price": 3999}, + map[string]any{"title": "Mountain boots", "price": 3899}, + map[string]any{"title": "Safety helmet", "price": 1999}, + }, + // sum filter test data + "sum_ints": []any{1, 2}, + "sum_mixed": []any{1, 2, "3", "4"}, + "sum_floats": []any{0.1, 0.2, 0.3}, + "sum_neg_floats": []any{0.1, -0.2, -0.3}, + "sum_objects": []any{ + map[string]any{"quantity": 1}, + map[string]any{"quantity": 2, "weight": 3}, + map[string]any{"weight": 4}, + }, + // compact with property arg test data [ruby] + "compact_with_nil_prop": []any{ + map[string]any{"name": "a", "prop": "value"}, + map[string]any{"name": "b", "prop": "value"}, + map[string]any{"name": "c"}, + }, + // uniq with property arg test data [ruby] + "dup_prop_objects": []any{ + map[string]any{"name": "a"}, + map[string]any{"name": "a"}, + map[string]any{"name": "b"}, + }, } func TestFilters(t *testing.T) { @@ -368,3 +632,565 @@ func timeMustParse(s string) time.Time { return t } + +// TestSampleFilter tests the sample filter. [liquidjs: test/integration/filters/array.spec.ts] +func TestSampleFilter(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "fruits": []any{"apples", "oranges", "peaches", "plums"}, + "empty_arr": []any{}, + } + context := expressions.NewContext(bindings, cfg) + + // sample returns a single element from the array + t.Run("single_element", func(t *testing.T) { + actual, err := expressions.EvaluateString(`fruits | sample`, context) + require.NoError(t, err) + require.Contains(t, []any{"apples", "oranges", "peaches", "plums"}, actual) + }) + + // sample with count returns array of that size + t.Run("with_count", func(t *testing.T) { + actual, err := expressions.EvaluateString(`fruits | sample: 2`, context) + require.NoError(t, err) + arr, ok := actual.([]any) + require.True(t, ok) + require.Len(t, arr, 2) + }) + + // sample with count > len returns entire array + t.Run("count_exceeds_length", func(t *testing.T) { + actual, err := expressions.EvaluateString(`fruits | sample: 10`, context) + require.NoError(t, err) + arr, ok := actual.([]any) + require.True(t, ok) + require.Len(t, arr, 4) + }) + + // empty array: nil input returns empty [liquidjs: `{{ nil | sample: 2 }}`] + t.Run("empty_array", func(t *testing.T) { + actual, err := expressions.EvaluateString(`empty_arr | sample`, context) + require.NoError(t, err) + require.Nil(t, actual) + }) + + t.Run("empty_array_with_count", func(t *testing.T) { + actual, err := expressions.EvaluateString(`empty_arr | sample: 2`, context) + require.NoError(t, err) + require.Equal(t, []any{}, actual) + }) +} + +// TestWhereFilterEdgeCases tests where filter edge cases. [liquidjs: test/integration/filters/array.spec.ts] +func TestWhereFilterEdgeCases(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "products": []any{ + map[string]any{"title": "Vacuum", "type": "living room"}, + map[string]any{"title": "Spatula", "type": "kitchen"}, + map[string]any{"title": "Television", "type": "living room"}, + map[string]any{"title": "Garlic press", "type": "kitchen"}, + map[string]any{"title": "Coffee mug", "available": true}, + map[string]any{"title": "Sneakers", "available": false}, + map[string]any{"title": "Boring sneakers", "available": true}, + }, + "empty_array": []any{}, + } + context := expressions.NewContext(bindings, cfg) + + // where with property and value [liquidjs: `products | where: "type", "kitchen"`] + t.Run("with_value", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | where: "type", "kitchen" | map: "title" | join: ", "`, context) + require.NoError(t, err) + require.Equal(t, "Spatula, Garlic press", actual) + }) + + // where with truthy (no target value) [liquidjs: `products | where: "available"`] + t.Run("truthy", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | where: "available" | map: "title" | join: ", "`, context) + require.NoError(t, err) + require.Equal(t, "Coffee mug, Boring sneakers", actual) + }) + + // where on empty array + t.Run("empty_array", func(t *testing.T) { + actual, err := expressions.EvaluateString(`empty_array | where: "type", "x" | size`, context) + require.NoError(t, err) + require.Equal(t, 0, actual) + }) +} + +// TestRejectFilterEdgeCases tests reject filter edge cases. [liquidjs: test/integration/filters/array.spec.ts] +func TestRejectFilterEdgeCases(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "products": []any{ + map[string]any{"title": "Vacuum", "type": "living room"}, + map[string]any{"title": "Spatula", "type": "kitchen"}, + map[string]any{"title": "Television", "type": "living room"}, + map[string]any{"title": "Garlic press", "type": "kitchen"}, + map[string]any{"title": "Coffee mug", "available": true}, + map[string]any{"title": "Sneakers", "available": false}, + map[string]any{"title": "Boring sneakers", "available": true}, + }, + } + context := expressions.NewContext(bindings, cfg) + + // reject by value [liquidjs: `products | reject: "type", "kitchen"`] + t.Run("with_value", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | reject: "type", "kitchen" | map: "title" | join: ", "`, context) + require.NoError(t, err) + require.Equal(t, "Vacuum, Television, Coffee mug, Sneakers, Boring sneakers", actual) + }) + + // reject truthy (no target value) [liquidjs: `products | reject: "available"`] + t.Run("truthy", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | reject: "available" | map: "title" | join: ", "`, context) + require.NoError(t, err) + require.Equal(t, "Vacuum, Spatula, Television, Garlic press, Sneakers", actual) + }) + + // reject by property existence [liquidjs: `products | reject: "type"`] + t.Run("by_property", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | reject: "type" | map: "title" | join: ", "`, context) + require.NoError(t, err) + require.Equal(t, "Coffee mug, Sneakers, Boring sneakers", actual) + }) +} + +// TestGroupByFilter tests the group_by filter. [liquidjs: test/integration/filters/array.spec.ts] +func TestGroupByFilter(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "members": []any{ + map[string]any{"graduation_year": 2003, "name": "Jay"}, + map[string]any{"graduation_year": 2003, "name": "John"}, + map[string]any{"graduation_year": 2004, "name": "Jack"}, + }, + } + context := expressions.NewContext(bindings, cfg) + + t.Run("basic", func(t *testing.T) { + actual, err := expressions.EvaluateString(`members | group_by: "graduation_year" | inspect`, context) + require.NoError(t, err) + require.Equal(t, `[{"items":[{"graduation_year":2003,"name":"Jay"},{"graduation_year":2003,"name":"John"}],"name":2003},{"items":[{"graduation_year":2004,"name":"Jack"}],"name":2004}]`, actual) + }) +} + +// TestSumFilterEdgeCases tests sum filter edge cases. [ruby: standard_filter_test.rb] +func TestSumFilterEdgeCases(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "with_nil": []any{1, nil, 2}, + "with_true": []any{1, true, nil}, + "with_string": []any{1, "foo", map[string]any{"quantity": 3}}, + } + context := expressions.NewContext(bindings, cfg) + + // nil values are skipped [ruby: sum([1, nil, ...])] + t.Run("with_nil", func(t *testing.T) { + actual, err := expressions.EvaluateString(`with_nil | sum`, context) + require.NoError(t, err) + require.Equal(t, int64(3), actual) + }) + + // non-numeric values (strings, maps) are skipped [ruby: sum([1, [2], "foo", { "quantity" => 3 }]) = 3] + t.Run("with_string", func(t *testing.T) { + actual, err := expressions.EvaluateString(`with_string | sum`, context) + require.NoError(t, err) + require.Equal(t, int64(1), actual) + }) +} + +// TestFindFilterEdgeCases tests find filter edge cases. +func TestFindFilterEdgeCases(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "members": []any{ + map[string]any{"graduation_year": 2013, "name": "Jay"}, + map[string]any{"graduation_year": 2014, "name": "John"}, + map[string]any{"graduation_year": 2014, "name": "Jack", "age": 13}, + }, + "empty_array": []any{}, + } + context := expressions.NewContext(bindings, cfg) + + // find by truthy property (no value) [liquidjs: `members | find: "age"`] + t.Run("truthy", func(t *testing.T) { + actual, err := expressions.EvaluateString(`members | find: "age" | inspect`, context) + require.NoError(t, err) + require.Equal(t, `{"age":13,"graduation_year":2014,"name":"Jack"}`, actual) + }) + + // find not found returns nil [liquidjs: `members | find: "graduation_year", 2018`] + t.Run("not_found", func(t *testing.T) { + actual, err := expressions.EvaluateString(`members | find: "graduation_year", 2018`, context) + require.NoError(t, err) + require.Nil(t, actual) + }) + + // find on empty array returns nil [ruby: products | find: 'title.content', 'Not found'] + t.Run("empty_array", func(t *testing.T) { + actual, err := expressions.EvaluateString(`empty_array | find: "price", 100`, context) + require.NoError(t, err) + require.Nil(t, actual) + }) +} + +// TestHasFilterEdgeCases tests has filter edge cases. +func TestHasFilterEdgeCases(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{ + "empty_array": []any{}, + "members": []any{ + map[string]any{"graduation_year": 2013, "name": "Jay"}, + map[string]any{"graduation_year": 2014, "name": "John"}, + map[string]any{"graduation_year": 2014, "name": "Jack", "age": 13}, + }, + } + context := expressions.NewContext(bindings, cfg) + + // has on empty array returns false [ruby: has([], 'foo', 'bar') = false] + t.Run("empty_array", func(t *testing.T) { + actual, err := expressions.EvaluateString(`empty_array | has: "foo", "bar"`, context) + require.NoError(t, err) + require.Equal(t, false, actual) + }) + + // has truthy checks if any item has a truthy property [liquidjs: `members | has: "age"`] + t.Run("truthy", func(t *testing.T) { + actual, err := expressions.EvaluateString(`members | has: "age"`, context) + require.NoError(t, err) + require.Equal(t, true, actual) + }) + + // has truthy not found returns false [liquidjs: `members | has: "height"`] + t.Run("truthy_not_found", func(t *testing.T) { + actual, err := expressions.EvaluateString(`members | has: "height"`, context) + require.NoError(t, err) + require.Equal(t, false, actual) + }) +} + +// TestPushFilterImmutability verifies push does not mutate the original array. [liquidjs: test/integration/filters/array.spec.ts] +func TestPushFilterImmutability(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + original := []any{"hey"} + bindings := map[string]any{ + "val": original, + } + context := expressions.NewContext(bindings, cfg) + + actual, err := expressions.EvaluateString(`val | push: "foo" | join: ","`, context) + require.NoError(t, err) + require.Equal(t, "hey,foo", actual) + + // Original should not be mutated + require.Equal(t, []any{"hey"}, original) +} + +// TestPopFilterImmutability verifies pop does not mutate the original array. [liquidjs: test/integration/filters/array.spec.ts] +func TestPopFilterImmutability(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + original := []any{"hey", "you"} + bindings := map[string]any{ + "val": original, + } + context := expressions.NewContext(bindings, cfg) + + actual, err := expressions.EvaluateString(`val | pop | join: ","`, context) + require.NoError(t, err) + require.Equal(t, "hey", actual) + + // Original should not be mutated + require.Equal(t, []any{"hey", "you"}, original) +} + +// TestUnshiftFilterImmutability verifies unshift does not mutate the original array. [liquidjs: test/integration/filters/array.spec.ts] +func TestUnshiftFilterImmutability(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + original := []any{"you"} + bindings := map[string]any{ + "val": original, + } + context := expressions.NewContext(bindings, cfg) + + actual, err := expressions.EvaluateString(`val | unshift: "hey" | join: ", "`, context) + require.NoError(t, err) + require.Equal(t, "hey, you", actual) + + // Original should not be mutated + require.Equal(t, []any{"you"}, original) +} + +// TestShiftFilterImmutability verifies shift does not mutate the original array. [liquidjs: test/integration/filters/array.spec.ts] +func TestShiftFilterImmutability(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + original := []any{"hey", "you"} + bindings := map[string]any{ + "val": original, + } + context := expressions.NewContext(bindings, cfg) + + actual, err := expressions.EvaluateString(`val | shift | join: ","`, context) + require.NoError(t, err) + require.Equal(t, "you", actual) + + // Original should not be mutated + require.Equal(t, []any{"hey", "you"}, original) +} + +// TestZeroDivisionError verifies that divided_by and modulo return a typed ZeroDivisionError. +func TestZeroDivisionError(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + bindings := map[string]any{} + context := expressions.NewContext(bindings, cfg) + + t.Run("divided_by_zero_is_ZeroDivisionError", func(t *testing.T) { + _, err := expressions.EvaluateString(`20 | divided_by: 0`, context) + require.Error(t, err) + var zde *ZeroDivisionError + require.True(t, errors.As(err, &zde), "expected ZeroDivisionError, got %T: %v", err, err) + }) + + t.Run("modulo_zero_is_ZeroDivisionError", func(t *testing.T) { + _, err := expressions.EvaluateString(`20 | modulo: 0`, context) + require.Error(t, err) + var zde *ZeroDivisionError + require.True(t, errors.As(err, &zde), "expected ZeroDivisionError, got %T: %v", err, err) + }) + + t.Run("modulo_nonzero_succeeds", func(t *testing.T) { + actual, err := expressions.EvaluateString(`10 | modulo: 3`, context) + require.NoError(t, err) + require.Equal(t, int64(1), actual) + }) +} + +// TestExpFilters contains integration tests for all _exp context-aware filters. +func TestExpFilters(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + + products := []any{ + map[string]any{"title": "Vacuum", "type": "appliance", "price": 45, "available": true}, + map[string]any{"title": "Spatula", "type": "kitchen", "price": 10, "available": true}, + map[string]any{"title": "Television", "type": "appliance", "price": 500, "available": false}, + map[string]any{"title": "Garlic press", "type": "kitchen", "price": 12, "available": true}, + } + bindings := map[string]any{ + "products": products, + "nums": []any{1, 2, 3, 4, 5}, + } + ctx := expressions.NewContext(bindings, cfg) + + // where_exp + t.Run("where_exp_truthy_property", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | where_exp: "p", "p.available"`, ctx) + require.NoError(t, err) + result := actual.([]any) + require.Len(t, result, 3) + }) + + t.Run("where_exp_comparison", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | where_exp: "p", "p.price > 20"`, ctx) + require.NoError(t, err) + result := actual.([]any) + require.Len(t, result, 2) + require.Equal(t, "Vacuum", result[0].(map[string]any)["title"]) + require.Equal(t, "Television", result[1].(map[string]any)["title"]) + }) + + t.Run("where_exp_type_filter", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | where_exp: "p", "p.type == \"appliance\""`, ctx) + require.NoError(t, err) + result := actual.([]any) + require.Len(t, result, 2) + }) + + t.Run("where_exp_empty_result", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | where_exp: "p", "p.price > 1000"`, ctx) + require.NoError(t, err) + require.Equal(t, []any{}, actual) + }) + + // reject_exp + t.Run("reject_exp_available", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | reject_exp: "p", "p.available"`, ctx) + require.NoError(t, err) + result := actual.([]any) + require.Len(t, result, 1) + require.Equal(t, "Television", result[0].(map[string]any)["title"]) + }) + + t.Run("reject_exp_comparison", func(t *testing.T) { + actual, err := expressions.EvaluateString(`nums | reject_exp: "n", "n > 3"`, ctx) + require.NoError(t, err) + result := actual.([]any) + require.Equal(t, []any{1, 2, 3}, result) + }) + + // group_by_exp + t.Run("group_by_exp_type", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | group_by_exp: "p", "p.type"`, ctx) + require.NoError(t, err) + groups := actual.([]any) + require.Len(t, groups, 2) + g0 := groups[0].(map[string]any) + require.Equal(t, "appliance", g0["name"]) + require.Len(t, g0["items"].([]any), 2) + g1 := groups[1].(map[string]any) + require.Equal(t, "kitchen", g1["name"]) + require.Len(t, g1["items"].([]any), 2) + }) + + // find_exp + t.Run("find_exp_first_match", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | find_exp: "p", "p.price > 20"`, ctx) + require.NoError(t, err) + item := actual.(map[string]any) + require.Equal(t, "Vacuum", item["title"]) + }) + + t.Run("find_exp_no_match", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | find_exp: "p", "p.price > 9999"`, ctx) + require.NoError(t, err) + require.Nil(t, actual) + }) + + // find_index_exp + t.Run("find_index_exp_found", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | find_index_exp: "p", "p.price > 20"`, ctx) + require.NoError(t, err) + require.Equal(t, 0, actual) + }) + + t.Run("find_index_exp_not_found", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | find_index_exp: "p", "p.price > 9999"`, ctx) + require.NoError(t, err) + require.Nil(t, actual) + }) + + t.Run("find_index_exp_second", func(t *testing.T) { + actual, err := expressions.EvaluateString(`nums | find_index_exp: "n", "n > 3"`, ctx) + require.NoError(t, err) + require.Equal(t, 3, actual) + }) + + // has_exp + t.Run("has_exp_true", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | has_exp: "p", "p.available and p.price < 15"`, ctx) + require.NoError(t, err) + require.Equal(t, true, actual) + }) + + t.Run("has_exp_false", func(t *testing.T) { + actual, err := expressions.EvaluateString(`products | has_exp: "p", "p.price > 9999"`, ctx) + require.NoError(t, err) + require.Equal(t, false, actual) + }) + + t.Run("has_exp_empty_array", func(t *testing.T) { + bindings2 := map[string]any{"arr": []any{}} + ctx2 := expressions.NewContext(bindings2, cfg) + actual, err := expressions.EvaluateString(`arr | has_exp: "x", "x > 1"`, ctx2) + require.NoError(t, err) + require.Equal(t, false, actual) + }) +} + +// TestDateToXmlschema verifies the date_to_xmlschema filter. +// Ported from LiquidJS: test/integration/filters/date.spec.ts — filters/date_to_xmlschema +func TestDateToXmlschema(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + + tests := []struct { + in string + pattern string // regexp pattern to match + exact string // if non-empty, exact match expected + }{ + { + // Date with explicit UTC-8 timezone — output preserves it. + in: `"2008-11-07T13:07:54-08:00" | date_to_xmlschema`, + exact: "2008-11-07T13:07:54-08:00", + }, + { + // Date without timezone → system-local offset appended. + in: `"1990-10-15T23:00:00" | date_to_xmlschema`, + pattern: `^1990-10-15T23:00:00[+-]\d{2}:\d{2}$`, + }, + { + // Invalid date → returned unchanged. + in: `"not-a-date" | date_to_xmlschema`, + exact: "not-a-date", + }, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + ctx := expressions.NewContext(map[string]any{}, cfg) + actual, err := expressions.EvaluateString(tt.in, ctx) + require.NoError(t, err) + if tt.exact != "" { + require.Equal(t, tt.exact, actual) + } else { + require.Regexp(t, regexp.MustCompile(tt.pattern), actual) + } + }) + } +} + +// TestDateToRfc822 verifies the date_to_rfc822 filter. +// Ported from LiquidJS: test/integration/filters/date.spec.ts — filters/date_to_rfc822 +func TestDateToRfc822(t *testing.T) { + cfg := expressions.NewConfig() + AddStandardFilters(&cfg) + + tests := []struct { + in string + pattern string + exact string + }{ + { + // Date with explicit UTC-8 timezone. + in: `"2008-11-07T13:07:54-08:00" | date_to_rfc822`, + exact: "Fri, 07 Nov 2008 13:07:54 -0800", + }, + { + // Date without timezone → system's local offset. + in: `"1990-10-15T23:00:00" | date_to_rfc822`, + pattern: `^Mon, 15 Oct 1990 23:00:00 [+-]\d{4}$`, + }, + { + // Invalid date → returned unchanged. + in: `"not-a-date" | date_to_rfc822`, + exact: "not-a-date", + }, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + ctx := expressions.NewContext(map[string]any{}, cfg) + actual, err := expressions.EvaluateString(tt.in, ctx) + require.NoError(t, err) + if tt.exact != "" { + require.Equal(t, tt.exact, actual) + } else { + require.Regexp(t, regexp.MustCompile(tt.pattern), actual) + } + }) + } +} diff --git a/js_poc_parity_test.go b/js_poc_parity_test.go new file mode 100644 index 00000000..426342cd --- /dev/null +++ b/js_poc_parity_test.go @@ -0,0 +1,200 @@ +package liquid + +import ( + "strings" + "testing" +) + +// extractVars mirrors the JS extractVars function from liquid-poc.html. +// It uses GlobalVariableSegments (equivalent to globalVariableSegmentsSync) and +// joins each path with ".". +// NOTE: In Go, ALL segments are already strings ([][]string), so no need to stop at +// numeric/array segments — the tracking layer already handles that via IndexValue. +func extractVars(eng *Engine, templateStr string) ([]string, error) { + tpl, srcErr := eng.ParseString(templateStr) + if srcErr != nil { + return nil, srcErr + } + segs, err := tpl.GlobalVariableSegments() + if err != nil { + return nil, err + } + seen := map[string]bool{} + var result []string + for _, path := range segs { + key := strings.Join(path, ".") + if key != "" && !seen[key] { + seen[key] = true + result = append(result, key) + } + } + return result, nil +} + +func mustHave(t *testing.T, got []string, vars ...string) { + t.Helper() + set := map[string]bool{} + for _, v := range got { + set[v] = true + } + for _, v := range vars { + if !set[v] { + t.Errorf("missing %q in %v", v, got) + } + } +} + +func mustNotHave(t *testing.T, got []string, vars ...string) { + t.Helper() + set := map[string]bool{} + for _, v := range got { + set[v] = true + } + for _, v := range vars { + if set[v] { + t.Errorf("should not have %q in %v", v, got) + } + } +} + +func TestJSPoC_Parity(t *testing.T) { + eng := NewEngine() + + run := func(name, tpl string, have []string, notHave []string) { + t.Helper() + t.Run(name, func(t *testing.T) { + got, err := extractVars(eng, tpl) + if err != nil { + t.Fatalf("parse error: %v", err) + } + t.Logf("got: %v", got) + if len(have) > 0 { + mustHave(t, got, have...) + } + if len(notHave) > 0 { + mustNotHave(t, got, notHave...) + } + }) + } + + // ── 01 Básico ──────────────────────────────────────────────────────────────── + run("01 variável simples", `{{ nome }}`, ss("nome"), nil) + run("01 propriedade de objeto", `{{ customer.email }}`, ss("customer.email"), nil) + run("01 cadeia longa de propriedades", `{{ order.shipping.address.city }}`, ss("order.shipping.address.city"), nil) + run("01 múltiplas variáveis", `{{ customer.first_name }} comprou {{ product.name }} por {{ order.total }}`, + ss("customer.first_name", "product.name", "order.total"), nil) + run("01 acesso por índice numérico", `{{ items[0] }}`, + ss("items"), ss("items[0]")) + + // ── 02 Whitespace ──────────────────────────────────────────────────────────── + run("02 sem espaços", `{{customer.email}}`, ss("customer.email"), nil) + run("02 muitos espaços", `{{ customer.email }}`, ss("customer.email"), nil) + run("02 tab", "{{\tcustomer.email\t}}", ss("customer.email"), nil) + run("02 quebra de linha", "{{\n customer.email\n}}", ss("customer.email"), nil) + run("02 whitespace control", `{{- customer.email -}}`, ss("customer.email"), nil) + run("02 só traço de abertura", `{{- customer.email }}`, ss("customer.email"), nil) + run("02 só traço de fechamento", `{{ customer.email -}}`, ss("customer.email"), nil) + + // ── 03 Filters ─────────────────────────────────────────────────────────────── + run("03 filter simples", `{{ customer.name | upcase }}`, ss("customer.name"), nil) + run("03 vários filters encadeados", `{{ customer.bio | strip_html | truncate: 100 | upcase }}`, ss("customer.bio"), nil) + run("03 filter com argumento variável", `{{ customer.name | default: fallback.name }}`, + ss("customer.name", "fallback.name"), nil) + run("03 filter append com variável", `{{ product.slug | append: site.base_url }}`, + ss("product.slug", "site.base_url"), nil) + run("03 filter date", `{{ order.created_at | date: "%d/%m/%Y" }}`, ss("order.created_at"), nil) + + // ── 04 if / unless / case ──────────────────────────────────────────────────── + run("04 if simples", `{% if customer.active %}ativo{% endif %}`, ss("customer.active"), nil) + run("04 if com ==", `{% if customer.status == "premium" %}ok{% endif %}`, ss("customer.status"), nil) + run("04 if com espaços absurdos >=", `{% if customer.age >= 18 %}maior{% endif %}`, ss("customer.age"), nil) + run("04 if/elsif/else", `{% if customer.tier == "gold" %}gold{% elsif customer.tier == "silver" %}silver{% else %}free{% endif %}`, + ss("customer.tier"), nil) + run("04 unless", `{% unless customer.blocked %}mostrar{% endunless %}`, ss("customer.blocked"), nil) + run("04 if contains", `{% if customer.tags contains "vip" %}VIP{% endif %}`, ss("customer.tags"), nil) + run("04 if and/or", `{% if customer.active and customer.verified %}ok{% endif %}`, + ss("customer.active", "customer.verified"), nil) + run("04 if aninhado", `{% if order.exists %}{% if order.paid %}pago{% endif %}{% endif %}`, + ss("order.exists", "order.paid"), nil) + run("04 case/when", `{% case customer.plan %}{% when "free" %}grátis{% when "pro" %}pago{% endcase %}`, + ss("customer.plan"), nil) + + // ── 05 for ─────────────────────────────────────────────────────────────────── + run("05 for simples — só coleção, props do item são locais", + `{% for item in cart.items %}{{ item.name }}{% endfor %}`, + ss("cart.items"), ss("item", "item.name")) + run("05 for com limit/offset externo", + `{% for p in products limit: 5 offset: page.offset %}{{ p.title }}{% endfor %}`, + ss("products", "page.offset"), ss("p", "p.title")) + run("05 for com forloop.index", + `{% for item in order.items %}{{ forloop.index }}: {{ item.name }}{% endfor %}`, + ss("order.items"), ss("item", "item.name", "forloop", "forloop.index")) + run("05 for aninhado", + `{% for order in customer.orders %}{% for item in order.items %}{{ item.sku }}{% endfor %}{% endfor %}`, + ss("customer.orders"), ss("order", "item", "order.items", "item.sku")) + run("05 for com break condicional", + `{% for item in list %}{% if item.stop %}{% break %}{% endif %}{{ item.value }}{% endfor %}`, + ss("list"), ss("item", "item.stop", "item.value")) + + // ── 06 assign / capture ────────────────────────────────────────────────────── + run("06 assign — fonte é global, variável é local", + `{% assign full_name = customer.first_name %}{{ full_name }}`, + ss("customer.first_name"), ss("full_name")) + run("06 assign com filter", + `{% assign slug = product.title | downcase | replace: ' ', '-' %}{{ slug }}`, + ss("product.title"), ss("slug")) + run("06 capture — variável capturada é local", + `{% capture greeting %}Olá, {{ customer.name }}{% endcapture %}{{ greeting }}`, + ss("customer.name"), ss("greeting")) + + // ── 07 Sintaxes disruptivas ────────────────────────────────────────────────── + run("07 tag if sem espaço após nome", + `{%if customer.active%}ok{%endif%}`, + ss("customer.active"), nil) + run("07 tag com whitespace control e sem espaços", + `{%-if customer.active-%}ok{%-endif-%}`, + ss("customer.active"), nil) + run("07 quebra de linha dentro de tag", + "{%\n if\n customer.active\n%}ok{%\n endif\n%}", + ss("customer.active"), nil) + run("07 várias variáveis em linha única", + `Olá {{customer.first_name}}, seu pedido #{{order.id}} de {{order.total}} chegará em {{order.eta}}.`, + ss("customer.first_name", "order.id", "order.total", "order.eta"), nil) + run("07 template em linha com for+if", + `{% for i in order.items %}{% if i.available %}{{i.name}} - {{i.price}}{% endif %}{% endfor %}`, + ss("order.items"), ss("i", "i.name", "i.price", "i.available")) + run("07 acesso dinâmico por variável como índice", + `{{ matrix[row.index][col.key] }}`, + ss("matrix", "row.index", "col.key"), ss("matrix[row.index][col.key]")) + run("07 filter com múltiplos argumentos variáveis", + `{{ msg.body | replace: search.term, replace.value }}`, + ss("msg.body", "search.term", "replace.value"), nil) + run("07 comparação variável em ambos os lados", + `{% if user.role == config.required_role %}ok{% endif %}`, + ss("user.role", "config.required_role"), nil) + run("07 unless com != sem espaços", + `{%unless order.status!="cancelado" %}ativo{%endunless%}`, + ss("order.status"), nil) + run("07 acesso por string literal como chave", + `{{ customer["first_name"] }}`, + ss("customer.first_name"), nil) + + // ── 08 Edge cases ──────────────────────────────────────────────────────────── + run("08 assign+for com mesmo nome", + `{% assign item = global.item %}{% for item in list %}{{ item.x }}{% endfor %}{{ item }}`, + ss("global.item", "list"), ss("item", "item.x")) + run("08 template sem variáveis", + `

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 := "

    John

    \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, "
    ", + `{% 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": "
    {{ title }}
    ", + "footer.html": "
    © {{ year }}
    ", + }}) + out := s8render(t, eng, + `{% include "header.html" %}{% include "footer.html" %}`, + map[string]any{"title": "Home", "year": 2025}, + ) + require.Equal(t, "
    Home
    © 2025
    ", 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
    © 2025 My Blog
    ", 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, "