From a7fc298b76fa34cc9f461341e0e5c2127c4bcaee Mon Sep 17 00:00:00 2001 From: Michael Visser Date: Tue, 7 Apr 2026 15:14:30 -0500 Subject: [PATCH 1/3] feat: add standard Shopify Liquid filters Add 8 documented Shopify Liquid filters: where, sum, at_least, at_most, pluralize, handleize/handle, remove_last, and replace_last. Uses integer-aware arithmetic in sum and at_least/at_most to match existing filter conventions. Includes nil-safety guard in where filter's equality check to prevent reflect.TypeOf(nil) panic. Co-Authored-By: Claude Opus 4.6 (1M context) --- filters/standard_filters.go | 108 ++++++++++++++++++++++++++++++- filters/standard_filters_test.go | 41 ++++++++++++ parser/parser_test.go | 2 +- tags/iteration_tags.go | 1 - 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/filters/standard_filters.go b/filters/standard_filters.go index 2a9bbfc..2b6d127 100644 --- a/filters/standard_filters.go +++ b/filters/standard_filters.go @@ -9,8 +9,8 @@ import ( "math" "net/url" "reflect" - "strconv" "regexp" + "strconv" "strings" "time" "unicode" @@ -172,6 +172,8 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo return a[len(a)-1] }) fd.AddFilter("uniq", uniqFilter) + fd.AddFilter("where", whereFilter) + fd.AddFilter("sum", sumFilter) // date filters fd.AddFilter("date", func(t time.Time, format func(string) string) (string, error) { @@ -259,11 +261,43 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo return math.Floor(n*exp+0.5) / exp }) + fd.AddFilter("at_least", func(a, b any) any { + if isIntegerType(a) && isIntegerType(b) { + if toInt64(a) < toInt64(b) { + return b + } + return a + } + if toFloat64(a) < toFloat64(b) { + return b + } + return a + }) + fd.AddFilter("at_most", func(a, b any) any { + if isIntegerType(a) && isIntegerType(b) { + if toInt64(a) > toInt64(b) { + return b + } + return a + } + if toFloat64(a) > toFloat64(b) { + return b + } + return a + }) // sequence filters fd.AddFilter("size", values.Length) // string filters + fd.AddFilter("pluralize", func(count any, singular, plural string) string { + if toFloat64(count) == 1.0 { + return singular + } + return plural + }) + fd.AddFilter("handleize", handleizeFilter) + fd.AddFilter("handle", handleizeFilter) fd.AddFilter("append", func(s, suffix string) string { return s + suffix }) @@ -293,10 +327,24 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo fd.AddFilter("remove_first", func(s, old string) string { return strings.Replace(s, old, "", 1) }) + fd.AddFilter("remove_last", func(s, old string) string { + i := strings.LastIndex(s, old) + if i < 0 { + return s + } + return s[:i] + s[i+len(old):] + }) fd.AddFilter("replace", strings.ReplaceAll) fd.AddFilter("replace_first", func(s, old, n string) string { return strings.Replace(s, old, n, 1) }) + fd.AddFilter("replace_last", func(s, old, n string) string { + i := strings.LastIndex(s, old) + if i < 0 { + return s + } + return s[:i] + n + s[i+len(old):] + }) fd.AddFilter("sort_natural", sortNaturalFilter) fd.AddFilter("slice", func(v interface{}, start int, length func(int) int) interface{} { // Are we in the []byte case? Transform []byte to string @@ -473,6 +521,64 @@ func uniqFilter(a []any) (result []any) { return } +var handleizeRe = regexp.MustCompile(`[^a-z0-9]+`) + +func handleizeFilter(s string) string { + s = strings.ToLower(s) + s = handleizeRe.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + return s +} + +func whereFilter(a []any, key string, targetValue func(any) any) (result []any) { + keyValue := values.ValueOf(key) + target := targetValue(nil) + for _, obj := range a { + value := values.ValueOf(obj) + prop := value.PropertyValue(keyValue).Interface() + if target == nil { + // One-arg form: truthy check + if prop != nil && prop != false { + result = append(result, obj) + } + } else { + // Two-arg form: equality check — skip nil props to avoid + // reflect.TypeOf(nil) panic in eqItems + if prop != nil && eqItems(prop, target) { + result = append(result, obj) + } + } + } + return +} + +func sumFilter(a []any, key func(string) string) any { + prop := key("") + allInts := true + var intTotal int64 + var floatTotal float64 + for _, item := range a { + if prop != "" { + v := values.ValueOf(item) + item = v.PropertyValue(values.ValueOf(prop)).Interface() + } + if allInts && isIntegerType(item) { + intTotal += toInt64(item) + } else { + if allInts { + // Switch to float, carry over the int total so far + floatTotal = float64(intTotal) + allInts = false + } + floatTotal += toFloat64(item) + } + } + if allInts { + return intTotal + } + return floatTotal +} + func eqItems(a, b any) bool { if reflect.TypeOf(a).Comparable() && reflect.TypeOf(b).Comparable() { return a == b diff --git a/filters/standard_filters_test.go b/filters/standard_filters_test.go index a32eb66..8a8973a 100644 --- a/filters/standard_filters_test.go +++ b/filters/standard_filters_test.go @@ -51,6 +51,15 @@ var filterTests = []struct { {`dup_ints | uniq | join`, "1 2 3"}, {`dup_strings | uniq | join`, "one two three"}, {`dup_maps | uniq | map: "name" | join`, "m1 m2 m3"}, + // where + {`products | where: "available" | map: "title" | join: ", "`, "Shirt, Pants"}, + {`products | where: "type", "Shirt" | map: "title" | join: ", "`, "Shirt"}, + {`products | where: "price", 10.0 | map: "title" | join: ", "`, "Shirt"}, + // sum + {`"1,2,3" | split: "," | sum`, 6.0}, + {`prices | sum`, int64(30)}, + {`products | sum: "price"`, 30.0}, + {`mixed_case_array | sort_natural | join`, "a B c"}, {`mixed_case_hash_values | sort_natural: 'key' | map: 'key' | join`, "a B c"}, @@ -89,6 +98,22 @@ var filterTests = []struct { {`"Straße" | size`, 6}, // string filters + // pluralize + {`1 | pluralize: "item", "items"`, "item"}, + {`2 | pluralize: "item", "items"`, "items"}, + {`0 | pluralize: "item", "items"`, "items"}, + {`1.0 | pluralize: "item", "items"`, "item"}, + {`1.5 | pluralize: "item", "items"`, "items"}, + {`"1" | pluralize: "item", "items"`, "item"}, + {`"2" | pluralize: "item", "items"`, "items"}, + + // handleize / handle + {`"100% M & Ms!!!" | handleize`, "100-m-ms"}, + {`"Hello World" | handleize`, "hello-world"}, + {`"" | handleize`, ""}, + {`"---already---" | handleize`, "already"}, + {`"Hello World" | handle`, "hello-world"}, + {`"Take my protein pills and put my helmet on" | replace: "my", "your"`, "Take your protein pills and put your helmet on"}, {`"Take my protein pills and put my helmet on" | replace_first: "my", "your"`, "Take your protein pills and put my helmet on"}, {`"/my/fancy/url" | append: ".html"`, "/my/fancy/url.html"}, @@ -104,6 +129,10 @@ var filterTests = []struct { {`"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 "}, {`"I strained to see the train through the rain" | remove_first: "rain"`, "I sted to see the train through the rain"}, + {`"Hello Hello Hello" | remove_last: "Hello"`, "Hello Hello "}, + {`"Hello" | remove_last: "xyz"`, "Hello"}, + {`"Hello Hello Hello" | replace_last: "Hello", "Goodbye"`, "Hello Hello Goodbye"}, + {`"Hello" | replace_last: "xyz", "abc"`, "Hello"}, {`"Liquid" | slice: 0`, "L"}, {`"Liquid @@ -228,6 +257,12 @@ Liquid" | slice: 2, 4`, "quid"}, {`str_int | plus: 1`, 11.0}, {`str_float | plus: 1.0`, 4.5}, + // at_least / at_most + {`4 | at_least: 5`, 5}, + {`6 | at_least: 5`, 6}, + {`4 | at_most: 5`, 4}, + {`6 | at_most: 5`, 5}, + {`3 | modulo: 2`, 1.0}, {`24 | modulo: 7`, 3.0}, // {`183.357 | modulo: 12 | `, 3.357}, // TODO test suit use inexact @@ -282,6 +317,12 @@ var filterTestBindings = map[string]any{ {"weight": 3}, {"weight": nil}, }, + "products": []map[string]any{ + {"title": "Shirt", "type": "Shirt", "price": 10.0, "available": true}, + {"title": "Pants", "type": "Pants", "price": 20.0, "available": true}, + {"title": "Hat", "type": "Hat", "price": nil, "available": false}, + }, + "prices": []any{10, 20}, "string_with_newlines": "\nHello\nthere\n", "dup_ints": []int{1, 2, 1, 3}, "dup_strings": []string{"one", "two", "one", "three"}, diff --git a/parser/parser_test.go b/parser/parser_test.go index 7ecdb5f..e72405f 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -83,7 +83,7 @@ func TestSourceError(t *testing.T) { loc := SourceLoc{Pathname: "test.html", LineNo: 5} token := Token{ SourceLoc: loc, - Source: "{% bad %}", + Source: "{% bad %}", } err := Errorf(&token, "something went wrong") diff --git a/tags/iteration_tags.go b/tags/iteration_tags.go index 6d1daab..b088d5c 100644 --- a/tags/iteration_tags.go +++ b/tags/iteration_tags.go @@ -352,4 +352,3 @@ type reverseWrapper struct { func (w reverseWrapper) Len() int { return w.i.Len() } func (w reverseWrapper) Index(i int) any { return w.i.Index(w.i.Len() - 1 - i) } - From 6658bdaaaecef5666d9ecfbdfc84b667c9a699bb Mon Sep 17 00:00:00 2001 From: Michael Visser Date: Tue, 7 Apr 2026 15:24:12 -0500 Subject: [PATCH 2/3] fix: use values.Equal in where filter for nil safety and numeric coercion Replace eqItems with values.Equal in whereFilter to fix two issues: - Explicit nil target (e.g. where: "price", nil) panicked via MustConvert(nil, interface{}) in the constant-arg function path - Mixed numeric types (int vs float64) failed to match because eqItems used strict Go == equality values.Equal already handles nil comparison and numeric type coercion, matching the behavior of Liquid's == operator. Co-Authored-By: Claude Opus 4.6 (1M context) --- filters/standard_filters.go | 6 +++--- filters/standard_filters_test.go | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/filters/standard_filters.go b/filters/standard_filters.go index 2b6d127..34a0553 100644 --- a/filters/standard_filters.go +++ b/filters/standard_filters.go @@ -542,9 +542,9 @@ func whereFilter(a []any, key string, targetValue func(any) any) (result []any) result = append(result, obj) } } else { - // Two-arg form: equality check — skip nil props to avoid - // reflect.TypeOf(nil) panic in eqItems - if prop != nil && eqItems(prop, target) { + // Two-arg form: equality check using Liquid-compatible + // comparison (handles nil, mixed int/float, etc.) + if values.Equal(prop, target) { result = append(result, obj) } } diff --git a/filters/standard_filters_test.go b/filters/standard_filters_test.go index 8a8973a..400e5ff 100644 --- a/filters/standard_filters_test.go +++ b/filters/standard_filters_test.go @@ -55,6 +55,7 @@ var filterTests = []struct { {`products | where: "available" | map: "title" | join: ", "`, "Shirt, Pants"}, {`products | where: "type", "Shirt" | map: "title" | join: ", "`, "Shirt"}, {`products | where: "price", 10.0 | map: "title" | join: ", "`, "Shirt"}, + {`products | where: "price", 10 | map: "title" | join: ", "`, "Shirt"}, // sum {`"1,2,3" | split: "," | sum`, 6.0}, {`prices | sum`, int64(30)}, From 510642263bbbc89dfbd5e641f3b2341652ca3ddd Mon Sep 17 00:00:00 2001 From: Michael Visser Date: Tue, 7 Apr 2026 16:51:27 -0500 Subject: [PATCH 3/3] fix: return normalized types from at_least/at_most filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the pattern used by plus/times/minus — return int64/float64 from toInt64/toFloat64 instead of the unconverted input values, ensuring consistent types when filters are chained. Co-Authored-By: Claude Opus 4.6 (1M context) --- filters/standard_filters.go | 28 ++++++++++++++++------------ filters/standard_filters_test.go | 8 ++++---- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/filters/standard_filters.go b/filters/standard_filters.go index 34a0553..fe03a64 100644 --- a/filters/standard_filters.go +++ b/filters/standard_filters.go @@ -263,27 +263,31 @@ func AddStandardFilters(fd FilterDictionary) { //nolint: gocyclo }) fd.AddFilter("at_least", func(a, b any) any { if isIntegerType(a) && isIntegerType(b) { - if toInt64(a) < toInt64(b) { - return b + ai, bi := toInt64(a), toInt64(b) + if ai < bi { + return bi } - return a + return ai } - if toFloat64(a) < toFloat64(b) { - return b + af, bf := toFloat64(a), toFloat64(b) + if af < bf { + return bf } - return a + return af }) fd.AddFilter("at_most", func(a, b any) any { if isIntegerType(a) && isIntegerType(b) { - if toInt64(a) > toInt64(b) { - return b + ai, bi := toInt64(a), toInt64(b) + if ai > bi { + return bi } - return a + return ai } - if toFloat64(a) > toFloat64(b) { - return b + af, bf := toFloat64(a), toFloat64(b) + if af > bf { + return bf } - return a + return af }) // sequence filters diff --git a/filters/standard_filters_test.go b/filters/standard_filters_test.go index 400e5ff..e3b62ab 100644 --- a/filters/standard_filters_test.go +++ b/filters/standard_filters_test.go @@ -259,10 +259,10 @@ Liquid" | slice: 2, 4`, "quid"}, {`str_float | plus: 1.0`, 4.5}, // at_least / at_most - {`4 | at_least: 5`, 5}, - {`6 | at_least: 5`, 6}, - {`4 | at_most: 5`, 4}, - {`6 | at_most: 5`, 5}, + {`4 | at_least: 5`, int64(5)}, + {`6 | at_least: 5`, int64(6)}, + {`4 | at_most: 5`, int64(4)}, + {`6 | at_most: 5`, int64(5)}, {`3 | modulo: 2`, 1.0}, {`24 | modulo: 7`, 3.0},