diff --git a/filters/standard_filters.go b/filters/standard_filters.go index 2a9bbfc..fe03a64 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,47 @@ 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) { + ai, bi := toInt64(a), toInt64(b) + if ai < bi { + return bi + } + return ai + } + af, bf := toFloat64(a), toFloat64(b) + if af < bf { + return bf + } + return af + }) + fd.AddFilter("at_most", func(a, b any) any { + if isIntegerType(a) && isIntegerType(b) { + ai, bi := toInt64(a), toInt64(b) + if ai > bi { + return bi + } + return ai + } + af, bf := toFloat64(a), toFloat64(b) + if af > bf { + return bf + } + return af + }) // 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 +331,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 +525,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 using Liquid-compatible + // comparison (handles nil, mixed int/float, etc.) + if values.Equal(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..e3b62ab 100644 --- a/filters/standard_filters_test.go +++ b/filters/standard_filters_test.go @@ -51,6 +51,16 @@ 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"}, + {`products | where: "price", 10 | 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 +99,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 +130,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 +258,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`, 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}, // {`183.357 | modulo: 12 | `, 3.357}, // TODO test suit use inexact @@ -282,6 +318,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) } -