diff --git a/README.md b/README.md index 09f4c8e..01075bf 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,69 @@ fmt.Println(out) See the [API documentation][godoc-url] for additional examples. +### Filters + +Filters transform template values. The library includes [standard Shopify Liquid filters](https://shopify.github.io/liquid/filters/abs/), and you can also define custom filters. + +#### Basic Filter + +```go +engine := liquid.NewEngine() +engine.RegisterFilter("has_prefix", strings.HasPrefix) + +out, _ := engine.ParseAndRenderString(`{{ title | has_prefix: "Intro" }}`, + map[string]any{"title": "Introduction"}) +// Output: true +``` + +#### Filter with Optional Arguments + +Use a function parameter to provide default values: + +```go +engine.RegisterFilter("inc", func(a int, b func(int) int) int { + return a + b(1) // b(1) provides default value +}) + +out, _ := engine.ParseAndRenderString(`{{ n | inc }}`, map[string]any{"n": 10}) +// Output: 11 + +out, _ = engine.ParseAndRenderString(`{{ n | inc: 5 }}`, map[string]any{"n": 10}) +// Output: 15 +``` + +#### Filters with Named Arguments + +Filters can accept named arguments by including a `map[string]any` parameter: + +```go +engine.RegisterFilter("img_url", func(image string, size string, opts map[string]any) string { + scale := 1 + if s, ok := opts["scale"].(int); ok { + scale = s + } + return fmt.Sprintf("https://cdn.example.com/%s?size=%s&scale=%d", image, size, scale) +}) + +// Use with named arguments +out, _ := engine.ParseAndRenderString( + `{{image | img_url: '580x', scale: 2}}`, + map[string]any{"image": "product.jpg"}) +// Output: https://cdn.example.com/product.jpg?size=580x&scale=2 + +// Named arguments are optional +out, _ = engine.ParseAndRenderString( + `{{image | img_url: '300x'}}`, + map[string]any{"image": "product.jpg"}) +// Output: https://cdn.example.com/product.jpg?size=300x&scale=1 +``` + +The named arguments syntax follows Shopify Liquid conventions: +- Named arguments use the format `name: value` +- Multiple arguments are comma-separated: `filter: pos_arg, name1: value1, name2: value2` +- Positional arguments come before named arguments +- If the filter function's last parameter is `map[string]any`, it receives all named arguments + ### Jekyll Compatibility This library was originally developed for [Gojekyll](https://github.com/osteele/gojekyll), a Go port of Jekyll. @@ -167,8 +230,6 @@ This section provides a comprehensive guide to using and extending the Liquid te These features of Shopify Liquid aren't implemented: -- Filter keyword parameters, for example `{{ image | img_url: '580x', scale: 2 - }}`. [[Issue #42](https://github.com/osteele/liquid/issues/42)] - Warn and lax [error modes](https://github.com/shopify/liquid#error-modes). - Non-strict filters. An undefined filter is currently an error. diff --git a/expressions/builders.go b/expressions/builders.go index ce004d1..041944a 100644 --- a/expressions/builders.go +++ b/expressions/builders.go @@ -19,7 +19,7 @@ func makeContainsExpr(e1, e2 func(Context) values.Value) func(Context) values.Va } } -func makeFilter(fn valueFn, name string, args []valueFn) valueFn { +func makeFilter(fn valueFn, name string, args []filterParam) valueFn { return func(ctx Context) values.Value { result, err := ctx.ApplyFilter(name, fn, args) if err != nil { diff --git a/expressions/context.go b/expressions/context.go index a60b16f..43a4339 100644 --- a/expressions/context.go +++ b/expressions/context.go @@ -4,7 +4,7 @@ import "github.com/osteele/liquid/values" // Context is the expression evaluation context. It maps variables names to values. type Context interface { - ApplyFilter(string, valueFn, []valueFn) (any, error) + ApplyFilter(string, valueFn, []filterParam) (any, error) // Clone returns a copy with a new variable binding map // (so that copy.Set does effect the source context.) Clone() Context diff --git a/expressions/expressions.y b/expressions/expressions.y index b6fa4df..feca553 100644 --- a/expressions/expressions.y +++ b/expressions/expressions.y @@ -23,7 +23,7 @@ func init() { cyclefn func(string) Cycle loop Loop loopmods loopModifiers - filter_params []valueFn + filter_params []filterParam } %type expr rel filtered cond %type filter_params @@ -142,9 +142,10 @@ filtered: ; filter_params: - expr { $$ = []valueFn{$1} } -| filter_params ',' expr - { $$ = append($1, $3) } + expr { $$ = []filterParam{{name: "", value: $1}} } +| KEYWORD expr { $$ = []filterParam{{name: $1, value: $2}} } +| filter_params ',' expr { $$ = append($1, filterParam{name: "", value: $3}) } +| filter_params ',' KEYWORD expr { $$ = append($1, filterParam{name: $3, value: $4}) } rel: filtered diff --git a/expressions/filters.go b/expressions/filters.go index 636ee5f..e991b1c 100644 --- a/expressions/filters.go +++ b/expressions/filters.go @@ -33,6 +33,12 @@ func (e FilterError) Error() string { type valueFn func(Context) values.Value +// filterParam represents a filter parameter that can be either positional or named +type filterParam struct { + name string // empty string for positional parameters + value valueFn // the parameter value expression +} + func (c *Config) ensureMapIsCreated() { if c.filters == nil { c.filters = make(map[string]interface{}) @@ -80,7 +86,7 @@ func isClosureInterfaceType(t reflect.Type) bool { return closureType.ConvertibleTo(t) && !interfaceType.ConvertibleTo(t) } -func (ctx *context) ApplyFilter(name string, receiver valueFn, params []valueFn) (any, error) { +func (ctx *context) ApplyFilter(name string, receiver valueFn, params []filterParam) (any, error) { filter, ok := ctx.filters[name] if !ok { panic(UndefinedFilter(name)) @@ -89,19 +95,58 @@ func (ctx *context) ApplyFilter(name string, receiver valueFn, params []valueFn) fr := reflect.ValueOf(filter) args := []any{receiver(ctx).Interface()} - for i, param := range params { - if i+1 < fr.Type().NumIn() && isClosureInterfaceType(fr.Type().In(i+1)) { - expr, err := Parse(param(ctx).Interface().(string)) + // Separate positional and named parameters + var positionalParams []filterParam + namedParams := make(map[string]any) + + for _, param := range params { + if param.name == "" { + positionalParams = append(positionalParams, param) + } else { + namedParams[param.name] = param.value(ctx).Interface() + } + } + + // Check if filter function accepts named arguments (last param is map[string]any or map[string]interface{}) + acceptsNamedArgs := false + namedArgsIndex := -1 + if fr.Type().NumIn() > 1 { + lastParamType := fr.Type().In(fr.Type().NumIn() - 1) + if lastParamType.Kind() == reflect.Map && + lastParamType.Key().Kind() == reflect.String && + (lastParamType.Elem().Kind() == reflect.Interface || lastParamType.Elem() == reflect.TypeOf((*any)(nil)).Elem()) { + acceptsNamedArgs = true + namedArgsIndex = fr.Type().NumIn() - 1 + } + } + + // Process positional parameters + for i, param := range positionalParams { + // Calculate the actual parameter index (1-based because receiver is first) + paramIdx := i + 1 + + // Skip the named args slot if it exists and we've reached it + if acceptsNamedArgs && paramIdx >= namedArgsIndex { + break + } + + if paramIdx < fr.Type().NumIn() && isClosureInterfaceType(fr.Type().In(paramIdx)) { + expr, err := Parse(param.value(ctx).Interface().(string)) if err != nil { panic(err) } args = append(args, closure{expr, ctx}) } else { - args = append(args, param(ctx).Interface()) + args = append(args, param.value(ctx).Interface()) } } + // Add named arguments map if the filter accepts them (always pass, even if empty) + if acceptsNamedArgs { + args = append(args, namedParams) + } + out, err := values.Call(fr, args) if err != nil { if e, ok := err.(*values.CallParityError); ok { diff --git a/expressions/filters_test.go b/expressions/filters_test.go index 77512e5..0d0811d 100644 --- a/expressions/filters_test.go +++ b/expressions/filters_test.go @@ -34,7 +34,7 @@ func TestContext_runFilter(t *testing.T) { return "<" + s + ">" }) ctx := NewContext(map[string]any{"x": 10}, cfg) - out, err := ctx.ApplyFilter("f1", receiver, []valueFn{}) + out, err := ctx.ApplyFilter("f1", receiver, []filterParam{}) require.NoError(t, err) require.Equal(t, "", out) @@ -43,7 +43,7 @@ func TestContext_runFilter(t *testing.T) { return fmt.Sprintf("(%s, %s)", a, b) }) ctx = NewContext(map[string]any{"x": 10}, cfg) - out, err = ctx.ApplyFilter("with_arg", receiver, []valueFn{constant("arg")}) + out, err = ctx.ApplyFilter("with_arg", receiver, []filterParam{{name: "", value: constant("arg")}}) require.NoError(t, err) require.Equal(t, "(self, arg)", out) @@ -51,7 +51,7 @@ func TestContext_runFilter(t *testing.T) { // TODO error return // extra argument - _, err = ctx.ApplyFilter("with_arg", receiver, []valueFn{constant(1), constant(2)}) + _, err = ctx.ApplyFilter("with_arg", receiver, []filterParam{{name: "", value: constant(1)}, {name: "", value: constant(2)}}) require.Error(t, err) require.Contains(t, err.Error(), "wrong number of arguments") require.Contains(t, err.Error(), "given 2") @@ -70,11 +70,125 @@ func TestContext_runFilter(t *testing.T) { return fmt.Sprintf("(%v, %v)", a, value), nil }) ctx = NewContext(map[string]any{"x": 10}, cfg) - out, err = ctx.ApplyFilter("closure", receiver, []valueFn{constant("x |add: y")}) + out, err = ctx.ApplyFilter("closure", receiver, []filterParam{{name: "", value: constant("x |add: y")}}) require.NoError(t, err) require.Equal(t, "(self, 11)", out) } +// TestNamedFilterArguments tests filters with named arguments +func TestNamedFilterArguments(t *testing.T) { + cfg := NewConfig() + constant := func(value any) valueFn { + return func(Context) values.Value { return values.ValueOf(value) } + } + receiver := constant("image.jpg") + + // Filter with named arguments + cfg.AddFilter("img_url", func(image string, size string, opts map[string]any) string { + scale := 1 + if s, ok := opts["scale"].(int); ok { + scale = s + } + return fmt.Sprintf("img_url(%s, %s, scale=%d)", image, size, scale) + }) + + ctx := NewContext(map[string]any{}, cfg) + + // Test with positional and named arguments + out, err := ctx.ApplyFilter("img_url", receiver, []filterParam{ + {name: "", value: constant("580x")}, + {name: "scale", value: constant(2)}, + }) + require.NoError(t, err) + require.Equal(t, "img_url(image.jpg, 580x, scale=2)", out) + + // Test with only positional argument (named args should be empty map) + out, err = ctx.ApplyFilter("img_url", receiver, []filterParam{ + {name: "", value: constant("300x")}, + }) + require.NoError(t, err) + require.Equal(t, "img_url(image.jpg, 300x, scale=1)", out) + + // Test with multiple named arguments + cfg.AddFilter("custom_filter", func(input string, opts map[string]any) string { + format := opts["format"] + name := opts["name"] + return fmt.Sprintf("custom(%s, format=%v, name=%v)", input, format, name) + }) + + out, err = ctx.ApplyFilter("custom_filter", receiver, []filterParam{ + {name: "format", value: constant("date")}, + {name: "name", value: constant("order.name")}, + }) + require.NoError(t, err) + require.Equal(t, "custom(image.jpg, format=date, name=order.name)", out) + + // Test mixing positional and named arguments + cfg.AddFilter("mixed_args", func(input string, pos1 string, pos2 int, opts map[string]any) string { + extra := "" + if e, ok := opts["extra"].(string); ok { + extra = e + } + return fmt.Sprintf("mixed(%s, %s, %d, extra=%s)", input, pos1, pos2, extra) + }) + + out, err = ctx.ApplyFilter("mixed_args", receiver, []filterParam{ + {name: "", value: constant("arg1")}, + {name: "", value: constant(42)}, + {name: "extra", value: constant("bonus")}, + }) + require.NoError(t, err) + require.Equal(t, "mixed(image.jpg, arg1, 42, extra=bonus)", out) +} + +// TestNamedFilterArgumentsParsing tests that named arguments are correctly parsed +func TestNamedFilterArgumentsParsing(t *testing.T) { + cfg := NewConfig() + cfg.AddFilter("test_filter", func(input string, opts map[string]any) string { + return fmt.Sprintf("input=%s, opts=%v", input, opts) + }) + + // Test parsing filter with named arguments from expression string + tests := []struct { + name string + expr string + expected string + }{ + { + name: "single named argument", + expr: "'test' | test_filter: scale: 2", + expected: "input=test, opts=map[scale:2]", + }, + { + name: "multiple named arguments", + expr: "'test' | test_filter: scale: 2, format: 'jpg'", + expected: "input=test, opts=map[format:jpg scale:2]", + }, + { + name: "no arguments", + expr: "'test' | test_filter", + expected: "input=test, opts=map[]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expr, err := Parse(tt.expr) + require.NoError(t, err) + ctx := NewContext(map[string]any{}, cfg) + val, err := expr.Evaluate(ctx) + require.NoError(t, err) + // Check that the result contains the expected key parts + result := fmt.Sprintf("%v", val) + require.Contains(t, result, "input=test") + if tt.name != "no arguments" { + // For tests with named args, verify they're present + require.Contains(t, result, "opts=map[") + } + }) + } +} + // TestAddSafeFilterNilMap verifies that AddSafeFilter doesn't panic // when called on a Config with nil filters map func TestAddSafeFilterNilMap(t *testing.T) { diff --git a/expressions/scanner.go b/expressions/scanner.go index 0c2aff4..9036c30 100644 --- a/expressions/scanner.go +++ b/expressions/scanner.go @@ -258,7 +258,6 @@ const expression_en_main int = 29 type lexer struct { parseValue - data []byte p, pe, cs int ts, te, act int @@ -292,24 +291,17 @@ func (lex *lexer) Lex(out *yySymType) int { //line scanner.go:296 { - var ( - _klen int - _trans int - _acts int - _nacts uint - _keys int - ) - + var _klen int + var _trans int + var _acts int + var _nacts uint + var _keys int if (lex.p) == (lex.pe) { goto _test_eof } - _resume: - _acts = int(_expression_from_state_actions[lex.cs]) - _nacts = uint(_expression_actions[_acts]) - _acts++ for ; _nacts > 0; _nacts-- { _acts++ @@ -328,9 +320,7 @@ func (lex *lexer) Lex(out *yySymType) int { _klen = int(_expression_single_lengths[lex.cs]) if _klen > 0 { _lower := int(_keys) - var _mid int - _upper := int(_keys + _klen - 1) for { if _upper < _lower { @@ -348,7 +338,6 @@ func (lex *lexer) Lex(out *yySymType) int { goto _match } } - _keys += _klen _trans += _klen } @@ -356,9 +345,7 @@ func (lex *lexer) Lex(out *yySymType) int { _klen = int(_expression_range_lengths[lex.cs]) if _klen > 0 { _lower := int(_keys) - var _mid int - _upper := int(_keys + (_klen << 1) - 2) for { if _upper < _lower { @@ -376,15 +363,12 @@ func (lex *lexer) Lex(out *yySymType) int { goto _match } } - _trans += _klen } _match: _trans = int(_expression_indicies[_trans]) - _eof_trans: - lex.cs = int(_expression_trans_targs[_trans]) if _expression_trans_actions[_trans] == 0 { @@ -393,7 +377,6 @@ func (lex *lexer) Lex(out *yySymType) int { _acts = int(_expression_trans_actions[_trans]) _nacts = uint(_expression_actions[_acts]) - _acts++ for ; _nacts > 0; _nacts-- { _acts++ @@ -435,7 +418,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = ASSIGN (lex.p)++ - goto _out } case 13: @@ -444,7 +426,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = CYCLE (lex.p)++ - goto _out } case 14: @@ -453,7 +434,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = LOOP (lex.p)++ - goto _out } case 15: @@ -462,7 +442,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = WHEN (lex.p)++ - goto _out } case 16: @@ -473,8 +452,8 @@ func (lex *lexer) Lex(out *yySymType) int { // TODO unescape \x out.val = string(lex.data[lex.ts+1 : lex.te-1]) (lex.p)++ - goto _out + } case 17: //line scanner.rl:119 @@ -482,7 +461,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = EQ (lex.p)++ - goto _out } case 18: @@ -491,7 +469,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = NEQ (lex.p)++ - goto _out } case 19: @@ -500,7 +477,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = GE (lex.p)++ - goto _out } case 20: @@ -509,7 +485,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = LE (lex.p)++ - goto _out } case 21: @@ -518,7 +493,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = DOTDOT (lex.p)++ - goto _out } case 22: @@ -528,7 +502,6 @@ func (lex *lexer) Lex(out *yySymType) int { tok = KEYWORD out.name = string(lex.data[lex.ts : lex.te-1]) (lex.p)++ - goto _out } case 23: @@ -538,7 +511,6 @@ func (lex *lexer) Lex(out *yySymType) int { tok = PROPERTY out.name = string(lex.data[lex.ts+1 : lex.te]) (lex.p)++ - goto _out } case 24: @@ -547,7 +519,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = int(lex.data[lex.ts]) (lex.p)++ - goto _out } case 25: @@ -556,16 +527,14 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p)-- { tok = LITERAL - n, err := strconv.ParseInt(lex.token(), 10, 64) if err != nil { panic(err) } - out.val = int(n) (lex.p)++ - goto _out + } case 26: //line scanner.rl:78 @@ -573,16 +542,14 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p)-- { tok = LITERAL - n, err := strconv.ParseFloat(lex.token(), 64) if err != nil { panic(err) } - out.val = n (lex.p)++ - goto _out + } case 27: //line scanner.rl:58 @@ -598,8 +565,8 @@ func (lex *lexer) Lex(out *yySymType) int { out.name = t (lex.p)++ - goto _out + } case 28: //line scanner.rl:133 @@ -609,7 +576,6 @@ func (lex *lexer) Lex(out *yySymType) int { tok = PROPERTY out.name = string(lex.data[lex.ts+1 : lex.te]) (lex.p)++ - goto _out } case 29: @@ -624,7 +590,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = int(lex.data[lex.ts]) (lex.p)++ - goto _out } case 31: @@ -632,16 +597,14 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p) = (lex.te) - 1 { tok = LITERAL - n, err := strconv.ParseInt(lex.token(), 10, 64) if err != nil { panic(err) } - out.val = int(n) (lex.p)++ - goto _out + } case 32: //line scanner.rl:136 @@ -649,7 +612,6 @@ func (lex *lexer) Lex(out *yySymType) int { { tok = int(lex.data[lex.ts]) (lex.p)++ - goto _out } case 33: @@ -662,8 +624,8 @@ func (lex *lexer) Lex(out *yySymType) int { tok = LITERAL out.val = lex.token() == "true" (lex.p)++ - goto _out + } case 9: { @@ -671,7 +633,6 @@ func (lex *lexer) Lex(out *yySymType) int { tok = LITERAL out.val = nil (lex.p)++ - goto _out } case 14: @@ -679,7 +640,6 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p) = (lex.te) - 1 tok = AND (lex.p)++ - goto _out } case 15: @@ -687,7 +647,6 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p) = (lex.te) - 1 tok = OR (lex.p)++ - goto _out } case 16: @@ -695,7 +654,6 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p) = (lex.te) - 1 tok = CONTAINS (lex.p)++ - goto _out } case 17: @@ -703,7 +661,6 @@ func (lex *lexer) Lex(out *yySymType) int { (lex.p) = (lex.te) - 1 tok = IN (lex.p)++ - goto _out } case 20: @@ -719,8 +676,8 @@ func (lex *lexer) Lex(out *yySymType) int { out.name = t (lex.p)++ - goto _out + } case 21: { @@ -745,9 +702,7 @@ func (lex *lexer) Lex(out *yySymType) int { _again: _acts = int(_expression_to_state_actions[lex.cs]) - _nacts = uint(_expression_actions[_acts]) - _acts++ for ; _nacts > 0; _nacts-- { _acts++ @@ -764,11 +719,9 @@ func (lex *lexer) Lex(out *yySymType) int { if (lex.p) != (lex.pe) { goto _resume } - _test_eof: { } - if (lex.p) == eof { if _expression_eof_trans[lex.cs] > 0 { _trans = int(_expression_eof_trans[lex.cs] - 1) diff --git a/expressions/y.go b/expressions/y.go index cf6117b..73fe505 100644 --- a/expressions/y.go +++ b/expressions/y.go @@ -30,7 +30,7 @@ type yySymType struct { cyclefn func(string) Cycle loop Loop loopmods loopModifiers - filter_params []valueFn + filter_params []filterParam } const LITERAL = 57346 @@ -101,55 +101,56 @@ var yyExca = [...]int8{ const yyPrivate = 57344 -const yyLast = 123 +const yyLast = 127 var yyAct = [...]int8{ - 9, 50, 45, 19, 2, 8, 82, 24, 14, 15, - 10, 11, 46, 35, 10, 11, 26, 36, 3, 4, - 5, 6, 49, 64, 26, 44, 46, 47, 55, 56, - 57, 58, 59, 60, 61, 62, 26, 12, 27, 74, - 40, 12, 25, 42, 65, 26, 27, 7, 66, 69, - 67, 48, 70, 71, 68, 73, 52, 41, 27, 39, - 22, 83, 37, 38, 75, 17, 51, 27, 20, 77, - 78, 1, 80, 81, 84, 85, 14, 15, 53, 54, - 79, 21, 26, 86, 76, 16, 87, 28, 29, 32, - 33, 43, 18, 23, 34, 63, 72, 0, 31, 30, - 26, 14, 15, 0, 27, 28, 29, 32, 33, 13, - 0, 0, 34, 0, 0, 0, 31, 30, 0, 0, - 0, 0, 27, + 9, 50, 45, 19, 2, 8, 83, 24, 46, 10, + 11, 89, 49, 35, 10, 11, 47, 36, 3, 4, + 5, 6, 44, 46, 10, 11, 74, 42, 55, 56, + 57, 58, 59, 60, 61, 62, 12, 26, 10, 11, + 25, 12, 14, 15, 65, 26, 26, 48, 66, 69, + 67, 12, 70, 71, 68, 73, 52, 64, 26, 27, + 40, 41, 85, 22, 76, 12, 51, 27, 27, 78, + 79, 17, 81, 82, 20, 84, 1, 14, 15, 39, + 27, 75, 86, 87, 88, 77, 26, 80, 90, 21, + 91, 28, 29, 32, 33, 53, 54, 16, 34, 63, + 43, 18, 31, 30, 26, 14, 15, 7, 27, 28, + 29, 32, 33, 13, 23, 72, 34, 0, 0, 0, + 31, 30, 37, 38, 0, 0, 27, } var yyPact = [...]int16{ - 10, -1000, 84, 60, 64, 55, 6, -1000, 20, 93, - -1000, -1000, 6, -1000, 6, 6, 33, 50, 18, -2, - -1000, 2, 35, -3, 38, 73, -1000, 6, 6, 6, - 6, 6, 6, 6, 6, 75, -9, -1000, -1000, 6, - -1000, -1000, -1000, -1000, 64, -1000, 64, -1000, 6, -1000, - -1000, 6, 6, -1000, 6, 9, 17, 17, 17, 17, - 17, 17, 17, 6, -1000, 59, -16, -16, 20, 17, - 38, 38, -22, 17, -1000, 29, -1000, -1000, -1000, 69, - -1000, -1000, 6, -1000, -1000, 6, 17, 17, + 10, -1000, 88, 66, 70, 58, 34, -1000, 18, 97, + -1000, -1000, 34, -1000, 34, 34, 53, 54, 2, -5, + -1000, -9, 31, -13, 38, 90, -1000, 34, 34, 34, + 34, 34, 34, 34, 34, 79, 25, -1000, -1000, 34, + -1000, -1000, -1000, -1000, 70, -1000, 70, -1000, 34, -1000, + -1000, 34, 34, -1000, 20, 51, 39, 39, 39, 39, + 39, 39, 39, 34, -1000, 60, -20, -20, 18, 39, + 38, 38, -22, 39, 34, -1000, 30, -1000, -1000, -1000, + 77, -1000, -1000, 5, 39, -1000, -1000, 34, 39, 34, + 39, 39, } var yyPgo = [...]int8{ - 0, 0, 47, 5, 4, 96, 93, 1, 92, 91, - 2, 85, 81, 80, 3, 71, + 0, 0, 107, 5, 4, 115, 114, 1, 101, 100, + 2, 97, 89, 87, 3, 76, } var yyR1 = [...]int8{ 0, 15, 15, 15, 15, 15, 11, 11, 11, 8, 9, 9, 10, 10, 6, 7, 7, 7, 14, 12, 13, 13, 13, 1, 1, 1, 1, 1, 1, 3, - 3, 3, 5, 5, 2, 2, 2, 2, 2, 2, - 2, 2, 4, 4, 4, + 3, 3, 5, 5, 5, 5, 2, 2, 2, 2, + 2, 2, 2, 2, 4, 4, 4, } var yyR2 = [...]int8{ 0, 2, 5, 3, 3, 3, 1, 2, 2, 2, 3, 1, 0, 3, 2, 0, 3, 3, 1, 4, 0, 2, 3, 1, 1, 2, 4, 5, 3, 1, - 3, 4, 1, 3, 1, 3, 3, 3, 3, 3, - 3, 3, 1, 3, 3, + 3, 4, 1, 2, 3, 4, 1, 3, 3, 3, + 3, 3, 3, 3, 1, 3, 3, } var yyChk = [...]int16{ @@ -160,20 +161,22 @@ var yyChk = [...]int16{ 7, 7, 25, -9, 27, -10, 28, 25, 16, 25, -7, 28, 18, 5, 6, -1, -1, -1, -1, -1, -1, -1, -1, 20, 32, -4, -14, -14, -3, -1, - -1, -1, -5, -1, 30, -1, 25, -10, -10, -13, - -7, -7, 28, 32, 5, 6, -1, -1, + -1, -1, -5, -1, 6, 30, -1, 25, -10, -10, + -13, -7, -7, 28, -1, 32, 5, 6, -1, 6, + -1, -1, } var yyDef = [...]int8{ - 0, -2, 0, 0, 0, 0, 0, 42, 34, 29, + 0, -2, 0, 0, 0, 0, 0, 44, 36, 29, 23, 24, 0, 1, 0, 0, 0, 6, 0, 12, 18, 0, 0, 0, 15, 0, 25, 0, 0, 0, - 0, 0, 0, 0, 0, 29, 0, 43, 44, 0, + 0, 0, 0, 0, 0, 29, 0, 45, 46, 0, 8, 7, 3, 9, 0, 11, 0, 4, 0, 5, - 14, 0, 0, 30, 0, 0, 35, 36, 37, 38, - 39, 40, 41, 0, 28, 0, 12, 12, 20, 29, - 15, 15, 31, 32, 26, 0, 2, 10, 13, 19, - 16, 17, 0, 27, 21, 0, 33, 22, + 14, 0, 0, 30, 0, 0, 37, 38, 39, 40, + 41, 42, 43, 0, 28, 0, 12, 12, 20, 29, + 15, 15, 31, 32, 0, 26, 0, 2, 10, 13, + 19, 16, 17, 0, 33, 27, 21, 0, 34, 0, + 22, 35, } var yyTok1 = [...]int8{ @@ -752,17 +755,29 @@ yydefault: yyDollar = yyS[yypt-1 : yypt+1] //line expressions.y:145 { - yyVAL.filter_params = []valueFn{yyDollar[1].f} + yyVAL.filter_params = []filterParam{{name: "", value: yyDollar[1].f}} } case 33: + yyDollar = yyS[yypt-2 : yypt+1] +//line expressions.y:146 + { + yyVAL.filter_params = []filterParam{{name: yyDollar[1].name, value: yyDollar[2].f}} + } + case 34: yyDollar = yyS[yypt-3 : yypt+1] //line expressions.y:147 { - yyVAL.filter_params = append(yyDollar[1].filter_params, yyDollar[3].f) + yyVAL.filter_params = append(yyDollar[1].filter_params, filterParam{name: "", value: yyDollar[3].f}) } case 35: + yyDollar = yyS[yypt-4 : yypt+1] +//line expressions.y:148 + { + yyVAL.filter_params = append(yyDollar[1].filter_params, filterParam{name: yyDollar[3].name, value: yyDollar[4].f}) + } + case 37: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:151 +//line expressions.y:152 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { @@ -770,9 +785,9 @@ yydefault: return values.ValueOf(a.Equal(b)) } } - case 36: + case 38: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:158 +//line expressions.y:159 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { @@ -780,9 +795,9 @@ yydefault: return values.ValueOf(!a.Equal(b)) } } - case 37: + case 39: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:165 +//line expressions.y:166 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { @@ -790,9 +805,9 @@ yydefault: return values.ValueOf(b.Less(a)) } } - case 38: + case 40: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:172 +//line expressions.y:173 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { @@ -800,9 +815,9 @@ yydefault: return values.ValueOf(a.Less(b)) } } - case 39: + case 41: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:179 +//line expressions.y:180 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { @@ -810,9 +825,9 @@ yydefault: return values.ValueOf(b.Less(a) || a.Equal(b)) } } - case 40: + case 42: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:186 +//line expressions.y:187 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { @@ -820,24 +835,24 @@ yydefault: return values.ValueOf(a.Less(b) || a.Equal(b)) } } - case 41: + case 43: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:193 +//line expressions.y:194 { yyVAL.f = makeContainsExpr(yyDollar[1].f, yyDollar[3].f) } - case 43: + case 45: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:198 +//line expressions.y:199 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { return values.ValueOf(fa(ctx).Test() && fb(ctx).Test()) } } - case 44: + case 46: yyDollar = yyS[yypt-3 : yypt+1] -//line expressions.y:204 +//line expressions.y:205 { fa, fb := yyDollar[1].f, yyDollar[3].f yyVAL.f = func(ctx Context) values.Value { diff --git a/liquid_test.go b/liquid_test.go index 2bc3efa..8265a2f 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -20,6 +20,64 @@ func TestIterationKeyedMap(t *testing.T) { require.Equal(t, "a=1.b=2.", out) } +// TestNamedFilterArgumentsIntegration tests issue #42 - named filter arguments +func TestNamedFilterArgumentsIntegration(t *testing.T) { + engine := NewEngine() + + // Register test filters that support named arguments + engine.RegisterFilter("img_url", func(image string, size string, opts map[string]any) string { + scale := 1 + if s, ok := opts["scale"].(int); ok { + scale = s + } + return fmt.Sprintf("https://cdn.example.com/%s?size=%s&scale=%d", image, size, scale) + }) + + engine.RegisterFilter("date", func(dateStr string, opts map[string]any) string { + format := "default" + if f, ok := opts["format"].(string); ok { + format = f + } + return fmt.Sprintf("date(%s, format=%s)", dateStr, format) + }) + + engine.RegisterFilter("t", func(key string, opts map[string]any) string { + name := "" + if n, ok := opts["name"].(string); ok { + name = n + } + return fmt.Sprintf("translate(%s, name=%s)", key, name) + }) + + vars := map[string]any{ + "image": "product.jpg", + "order": map[string]any{ + "created_at": "2023-01-15", + "name": "Order #123", + }, + } + + // Test case from issue #42: {{image | img_url: '580x', scale: 2}} + out, err := engine.ParseAndRenderString(`{{image | img_url: '580x', scale: 2}}`, vars) + require.NoError(t, err) + require.Equal(t, "https://cdn.example.com/product.jpg?size=580x&scale=2", out) + + // Test case from issue #42: {{ order.created_at | date: format: 'date' }} + out, err = engine.ParseAndRenderString(`{{ order.created_at | date: format: 'date' }}`, vars) + require.NoError(t, err) + require.Equal(t, "date(2023-01-15, format=date)", out) + + // Test case from issue #42: {{ 'customer.order.title' | t: name: order.name }} + out, err = engine.ParseAndRenderString(`{{ 'customer.order.title' | t: name: order.name }}`, vars) + require.NoError(t, err) + require.Equal(t, "translate(customer.order.title, name=Order #123)", out) + + // Test with mixed positional and named arguments + out, err = engine.ParseAndRenderString(`{{image | img_url: '300x'}}`, vars) + require.NoError(t, err) + require.Equal(t, "https://cdn.example.com/product.jpg?size=300x&scale=1", out) +} + func ExampleIterationKeyedMap() { vars := map[string]any{ "map": map[string]any{"a": 1},