From 910d4b25cbdea2ff1d959a67d218aa47d3ed0320 Mon Sep 17 00:00:00 2001 From: Oliver Steele Date: Wed, 28 Jun 2017 14:41:46 -0400 Subject: [PATCH] More filters --- expressions/evaluator_test.go | 4 ++- expressions/interpreter.go | 6 +++++ filters/filter_test.go | 43 ++++++++++++++++++++---------- filters/filters.go | 49 +++++++++++++++++++++++++++++++++++ generics/generics.go | 12 +++++++++ generics/generics_test.go | 6 +++++ 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/expressions/evaluator_test.go b/expressions/evaluator_test.go index 316cf82..7a852ee 100644 --- a/expressions/evaluator_test.go +++ b/expressions/evaluator_test.go @@ -30,6 +30,8 @@ var evaluatorTests = []struct { {`fruits.last`, "plums"}, {`empty_list.first`, nil}, {`empty_list.last`, nil}, + {`"abc".size`, 3}, + {`fruits.size`, 4}, // Indices {"ar[1]", "second"}, @@ -71,7 +73,7 @@ var evaluatorTests = []struct { var evaluatorTestContext = NewContext(map[string]interface{}{ "n": 123, "ar": []string{"first", "second", "third"}, - "empty_list": map[string]interface{}{}, + "empty_list": []interface{}{}, "fruits": []string{"apples", "oranges", "peaches", "plums"}, "obj": map[string]interface{}{ "a": "first", diff --git a/expressions/interpreter.go b/expressions/interpreter.go index 90a6828..2d00a91 100644 --- a/expressions/interpreter.go +++ b/expressions/interpreter.go @@ -17,6 +17,12 @@ func makeObjectPropertyEvaluator(obj func(Context) interface{}, attr string) fun return ref.Index(0).Interface() case "last": return ref.Index(ref.Len() - 1).Interface() + case "size": + return ref.Len() + } + case reflect.String: + if attr == "size" { + return ref.Len() } case reflect.Map: value := ref.MapIndex(reflect.ValueOf(attr)) diff --git a/filters/filter_test.go b/filters/filter_test.go index 4defe1e..fb2b8bd 100644 --- a/filters/filter_test.go +++ b/filters/filter_test.go @@ -32,12 +32,21 @@ var filterTests = []struct { // {`"now" | date: "%Y-%m-%d %H:%M"`, "2017-06-28 13:27"}, // list filters - // site.pages | map: 'category' | compact | join "," %} + // TODO sort_natural, uniq + {`pages | map: 'category' | join`, "business, celebrities, , lifestyle, sports, , technology"}, + {`pages | map: 'category' | compact | join`, "business, celebrities, lifestyle, sports, technology"}, {`"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}]`}, {`fruits | reverse | join: ", "`, "plums, peaches, oranges, apples"}, - // map, slice, sort_natural, size, uniq + {`fruits | first`, "apples"}, + {`fruits | last`, "plums"}, + {`empty_list | first`, nil}, + {`empty_list | last`, nil}, + + // sequence filters + {`"Ground control to Major Tom." | size`, 28}, + {`"apples, oranges, peaches, plums" | split: ", " | size`, 4}, // string filters {`"Take my protein pills and put my helmet on" | replace: "my", "your"`, "Take your protein pills and put your helmet on"}, @@ -54,13 +63,17 @@ 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"}, + {`"Liquid" | slice: 0`, "L"}, + {`"Liquid" | slice: 2`, "q"}, + {`"Liquid" | slice: 2, 5`, "quid"}, + {`"Liquid" | slice: -3, 2`, "ui"}, {`"Ground control to Major Tom." | truncate: 20`, "Ground control to..."}, {`"Ground control to Major Tom." | truncate: 25, ", and so on"`, "Ground control, and so on"}, {`"Ground control to Major Tom." | truncate: 20, ""`, "Ground control to Ma"}, + // TODO escape, newline_to_br, strip_html, strip_newlines, truncatewords, url_decode, url_encode // {`"Have you read 'James & the Giant Peach'?" | escape`, ""}, // {`"1 < 2 & 3" | escape_once`, ""}, // {`"1 < 2 & 3" | escape_once`, ""}, - // newline_to_br,strip_html, strip_newlines, truncatewords, // url_decode, url_encode // number filters {`-17 | abs`, 17}, @@ -79,7 +92,7 @@ var filterTests = []struct { {`1.2 | floor`, 1}, {`2.0 | floor`, 2}, {`183.357 | floor`, 183}, - // minus, modulo, plus, round,times + // TODO divided_by, minus, modulo, plus, round,times // Jekyll extensions; added here for convenient testing // TODO add this just to the test environment @@ -100,19 +113,19 @@ var filterTestContext = expressions.NewContext(map[string]interface{}{ "article": map[string]interface{}{ "published_at": timeMustParse("2015-07-17T15:04:05Z"), }, - "empty_list": map[string]interface{}{}, + "empty_list": []interface{}{}, "fruits": []string{"apples", "oranges", "peaches", "plums"}, "obj": map[string]interface{}{ "a": 1, }, "pages": []map[string]interface{}{ - {"category": "business"}, - {"category": "celebrities"}, - {}, - {"category": "lifestyle"}, - {"category": "sports"}, - {}, - {"category": "technology"}, + {"name": "page 1", "category": "business"}, + {"name": "page 2", "category": "celebrities"}, + {"name": "page 3"}, + {"name": "page 4", "category": "lifestyle"}, + {"name": "page 5", "category": "sports"}, + {"name": "page 6"}, + {"name": "page 7", "category": "technology"}, }, "sort_prop": []map[string]interface{}{ {"weight": 1}, @@ -128,10 +141,14 @@ var filterTestContext = expressions.NewContext(map[string]interface{}{ func TestFilters(t *testing.T) { for i, test := range filterTests { - t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) { + t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) { value, err := expressions.EvaluateExpr(test.in, filterTestContext) require.NoErrorf(t, err, test.in) expected := test.expected + switch v := value.(type) { + case int: + value = float64(v) + } switch ex := expected.(type) { case int: expected = float64(ex) diff --git a/filters/filters.go b/filters/filters.go index 29c5b22..ff66c4b 100644 --- a/filters/filters.go +++ b/filters/filters.go @@ -34,15 +34,48 @@ func DefineStandardFilters() { }) // lists + expressions.DefineFilter("compact", func(values []interface{}) interface{} { + out := []interface{}{} + for _, value := range values { + if value != nil { + out = append(out, value) + } + } + return out + }) expressions.DefineFilter("join", joinFilter) + expressions.DefineFilter("map", func(values []map[string]interface{}, key string) interface{} { + out := []interface{}{} + for _, obj := range values { + out = append(out, obj[key]) + } + return out + }) expressions.DefineFilter("reverse", reverseFilter) expressions.DefineFilter("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 + expressions.DefineFilter("first", func(values []interface{}) interface{} { + if len(values) == 0 { + return nil + } + return values[0] + }) + expressions.DefineFilter("last", func(values []interface{}) interface{} { + if len(values) == 0 { + return nil + } + return values[len(values)-1] + }) // numbers expressions.DefineFilter("abs", math.Abs) expressions.DefineFilter("ceil", math.Ceil) expressions.DefineFilter("floor", math.Floor) + // sequences + expressions.DefineFilter("size", generics.Length) + // strings expressions.DefineFilter("append", func(s, suffix string) string { return s + suffix @@ -76,6 +109,22 @@ func DefineStandardFilters() { expressions.DefineFilter("replace_first", func(s, old, new string) string { return strings.Replace(s, old, new, 1) }) + expressions.DefineFilter("slice", func(s string, start int, length interface{}) string { + n, ok := length.(int) + if !ok { + n = 1 + } + if start < 0 { + start = len(s) + start + } + if start >= len(s) { + return "" + } + if start+n > len(s) { + return s[start:] + } + return s[start : start+n] + }) expressions.DefineFilter("split", splitFilter) expressions.DefineFilter("strip", strings.TrimSpace) expressions.DefineFilter("lstrip", func(s string) string { diff --git a/generics/generics.go b/generics/generics.go index 81c5df2..c3722e6 100644 --- a/generics/generics.go +++ b/generics/generics.go @@ -34,3 +34,15 @@ func IsEmpty(value interface{}) bool { func IsTrue(value interface{}) bool { return value != nil && value != false } + +// Length returns the length of a string or array. In keeping with Liquid semantics, +// and contra Go, it does not return the size of a map. +func Length(value interface{}) int { + ref := reflect.ValueOf(value) + switch ref.Kind() { + case reflect.Array, reflect.Slice, reflect.String: + return ref.Len(); + default: + return 0 + } +} diff --git a/generics/generics_test.go b/generics/generics_test.go index 2741f2e..8d694bd 100644 --- a/generics/generics_test.go +++ b/generics/generics_test.go @@ -88,3 +88,9 @@ func TestLess(t *testing.T) { }) } } + +func TestLength(t *testing.T) { + require.Equal(t, 3, Length([]int{1, 2, 3})) + require.Equal(t, 3, Length("abc")) + require.Equal(t, 0, Length(map[string]int{"a": 1})) +}