Skip to content

Commit b5c0653

Browse files
claudeosteele
authored andcommitted
feat: add support for named filter arguments (fixes #42)
Implement filter parameters for Shopify Liquid compatibility. Filters can now accept named arguments alongside positional args. Examples: {{image | img_url: '580x', scale: 2}} {{ order.created_at | date: format: 'date' }} {{ 'customer.order.title' | t: name: order.name }} Filter functions receive named args as map[string]any in their last parameter. BREAKING CHANGE: The expressions.Context interface signature changed. External implementations of ApplyFilter must update their signature from ApplyFilter(string, valueFn, []valueFn) (any, error) to ApplyFilter(string, valueFn, []filterParam) (any, error) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8cf0b70 commit b5c0653

File tree

9 files changed

+380
-133
lines changed

9 files changed

+380
-133
lines changed

README.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,69 @@ fmt.Println(out)
5858

5959
See the [API documentation][godoc-url] for additional examples.
6060

61+
### Filters
62+
63+
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.
64+
65+
#### Basic Filter
66+
67+
```go
68+
engine := liquid.NewEngine()
69+
engine.RegisterFilter("has_prefix", strings.HasPrefix)
70+
71+
out, _ := engine.ParseAndRenderString(`{{ title | has_prefix: "Intro" }}`,
72+
map[string]any{"title": "Introduction"})
73+
// Output: true
74+
```
75+
76+
#### Filter with Optional Arguments
77+
78+
Use a function parameter to provide default values:
79+
80+
```go
81+
engine.RegisterFilter("inc", func(a int, b func(int) int) int {
82+
return a + b(1) // b(1) provides default value
83+
})
84+
85+
out, _ := engine.ParseAndRenderString(`{{ n | inc }}`, map[string]any{"n": 10})
86+
// Output: 11
87+
88+
out, _ = engine.ParseAndRenderString(`{{ n | inc: 5 }}`, map[string]any{"n": 10})
89+
// Output: 15
90+
```
91+
92+
#### Filters with Named Arguments
93+
94+
Filters can accept named arguments by including a `map[string]any` parameter:
95+
96+
```go
97+
engine.RegisterFilter("img_url", func(image string, size string, opts map[string]any) string {
98+
scale := 1
99+
if s, ok := opts["scale"].(int); ok {
100+
scale = s
101+
}
102+
return fmt.Sprintf("https://cdn.example.com/%s?size=%s&scale=%d", image, size, scale)
103+
})
104+
105+
// Use with named arguments
106+
out, _ := engine.ParseAndRenderString(
107+
`{{image | img_url: '580x', scale: 2}}`,
108+
map[string]any{"image": "product.jpg"})
109+
// Output: https://cdn.example.com/product.jpg?size=580x&scale=2
110+
111+
// Named arguments are optional
112+
out, _ = engine.ParseAndRenderString(
113+
`{{image | img_url: '300x'}}`,
114+
map[string]any{"image": "product.jpg"})
115+
// Output: https://cdn.example.com/product.jpg?size=300x&scale=1
116+
```
117+
118+
The named arguments syntax follows Shopify Liquid conventions:
119+
- Named arguments use the format `name: value`
120+
- Multiple arguments are comma-separated: `filter: pos_arg, name1: value1, name2: value2`
121+
- Positional arguments come before named arguments
122+
- If the filter function's last parameter is `map[string]any`, it receives all named arguments
123+
61124
### Jekyll Compatibility
62125

63126
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
167230

168231
These features of Shopify Liquid aren't implemented:
169232

170-
- Filter keyword parameters, for example `{{ image | img_url: '580x', scale: 2
171-
}}`. [[Issue #42](https://github.com/osteele/liquid/issues/42)]
172233
- Warn and lax [error modes](https://github.com/shopify/liquid#error-modes).
173234
- Non-strict filters. An undefined filter is currently an error.
174235

expressions/builders.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func makeContainsExpr(e1, e2 func(Context) values.Value) func(Context) values.Va
1919
}
2020
}
2121

22-
func makeFilter(fn valueFn, name string, args []valueFn) valueFn {
22+
func makeFilter(fn valueFn, name string, args []filterParam) valueFn {
2323
return func(ctx Context) values.Value {
2424
result, err := ctx.ApplyFilter(name, fn, args)
2525
if err != nil {

expressions/context.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "github.com/osteele/liquid/values"
44

55
// Context is the expression evaluation context. It maps variables names to values.
66
type Context interface {
7-
ApplyFilter(string, valueFn, []valueFn) (any, error)
7+
ApplyFilter(string, valueFn, []filterParam) (any, error)
88
// Clone returns a copy with a new variable binding map
99
// (so that copy.Set does effect the source context.)
1010
Clone() Context

expressions/expressions.y

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func init() {
2323
cyclefn func(string) Cycle
2424
loop Loop
2525
loopmods loopModifiers
26-
filter_params []valueFn
26+
filter_params []filterParam
2727
}
2828
%type<f> expr rel filtered cond
2929
%type<filter_params> filter_params
@@ -142,9 +142,10 @@ filtered:
142142
;
143143

144144
filter_params:
145-
expr { $$ = []valueFn{$1} }
146-
| filter_params ',' expr
147-
{ $$ = append($1, $3) }
145+
expr { $$ = []filterParam{{name: "", value: $1}} }
146+
| KEYWORD expr { $$ = []filterParam{{name: $1, value: $2}} }
147+
| filter_params ',' expr { $$ = append($1, filterParam{name: "", value: $3}) }
148+
| filter_params ',' KEYWORD expr { $$ = append($1, filterParam{name: $3, value: $4}) }
148149

149150
rel:
150151
filtered

expressions/filters.go

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ func (e FilterError) Error() string {
3333

3434
type valueFn func(Context) values.Value
3535

36+
// filterParam represents a filter parameter that can be either positional or named
37+
type filterParam struct {
38+
name string // empty string for positional parameters
39+
value valueFn // the parameter value expression
40+
}
41+
3642
func (c *Config) ensureMapIsCreated() {
3743
if c.filters == nil {
3844
c.filters = make(map[string]interface{})
@@ -80,7 +86,7 @@ func isClosureInterfaceType(t reflect.Type) bool {
8086
return closureType.ConvertibleTo(t) && !interfaceType.ConvertibleTo(t)
8187
}
8288

83-
func (ctx *context) ApplyFilter(name string, receiver valueFn, params []valueFn) (any, error) {
89+
func (ctx *context) ApplyFilter(name string, receiver valueFn, params []filterParam) (any, error) {
8490
filter, ok := ctx.filters[name]
8591
if !ok {
8692
panic(UndefinedFilter(name))
@@ -89,19 +95,58 @@ func (ctx *context) ApplyFilter(name string, receiver valueFn, params []valueFn)
8995
fr := reflect.ValueOf(filter)
9096
args := []any{receiver(ctx).Interface()}
9197

92-
for i, param := range params {
93-
if i+1 < fr.Type().NumIn() && isClosureInterfaceType(fr.Type().In(i+1)) {
94-
expr, err := Parse(param(ctx).Interface().(string))
98+
// Separate positional and named parameters
99+
var positionalParams []filterParam
100+
namedParams := make(map[string]any)
101+
102+
for _, param := range params {
103+
if param.name == "" {
104+
positionalParams = append(positionalParams, param)
105+
} else {
106+
namedParams[param.name] = param.value(ctx).Interface()
107+
}
108+
}
109+
110+
// Check if filter function accepts named arguments (last param is map[string]any or map[string]interface{})
111+
acceptsNamedArgs := false
112+
namedArgsIndex := -1
113+
if fr.Type().NumIn() > 1 {
114+
lastParamType := fr.Type().In(fr.Type().NumIn() - 1)
115+
if lastParamType.Kind() == reflect.Map &&
116+
lastParamType.Key().Kind() == reflect.String &&
117+
(lastParamType.Elem().Kind() == reflect.Interface || lastParamType.Elem() == reflect.TypeOf((*any)(nil)).Elem()) {
118+
acceptsNamedArgs = true
119+
namedArgsIndex = fr.Type().NumIn() - 1
120+
}
121+
}
122+
123+
// Process positional parameters
124+
for i, param := range positionalParams {
125+
// Calculate the actual parameter index (1-based because receiver is first)
126+
paramIdx := i + 1
127+
128+
// Skip the named args slot if it exists and we've reached it
129+
if acceptsNamedArgs && paramIdx >= namedArgsIndex {
130+
break
131+
}
132+
133+
if paramIdx < fr.Type().NumIn() && isClosureInterfaceType(fr.Type().In(paramIdx)) {
134+
expr, err := Parse(param.value(ctx).Interface().(string))
95135
if err != nil {
96136
panic(err)
97137
}
98138

99139
args = append(args, closure{expr, ctx})
100140
} else {
101-
args = append(args, param(ctx).Interface())
141+
args = append(args, param.value(ctx).Interface())
102142
}
103143
}
104144

145+
// Add named arguments map if the filter accepts them
146+
if acceptsNamedArgs && len(namedParams) > 0 {
147+
args = append(args, namedParams)
148+
}
149+
105150
out, err := values.Call(fr, args)
106151
if err != nil {
107152
if e, ok := err.(*values.CallParityError); ok {

expressions/filters_test.go

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestContext_runFilter(t *testing.T) {
3434
return "<" + s + ">"
3535
})
3636
ctx := NewContext(map[string]any{"x": 10}, cfg)
37-
out, err := ctx.ApplyFilter("f1", receiver, []valueFn{})
37+
out, err := ctx.ApplyFilter("f1", receiver, []filterParam{})
3838
require.NoError(t, err)
3939
require.Equal(t, "<self>", out)
4040

@@ -43,15 +43,15 @@ func TestContext_runFilter(t *testing.T) {
4343
return fmt.Sprintf("(%s, %s)", a, b)
4444
})
4545
ctx = NewContext(map[string]any{"x": 10}, cfg)
46-
out, err = ctx.ApplyFilter("with_arg", receiver, []valueFn{constant("arg")})
46+
out, err = ctx.ApplyFilter("with_arg", receiver, []filterParam{{name: "", value: constant("arg")}})
4747
require.NoError(t, err)
4848
require.Equal(t, "(self, arg)", out)
4949

5050
// TODO optional argument
5151
// TODO error return
5252

5353
// extra argument
54-
_, err = ctx.ApplyFilter("with_arg", receiver, []valueFn{constant(1), constant(2)})
54+
_, err = ctx.ApplyFilter("with_arg", receiver, []filterParam{{name: "", value: constant(1)}, {name: "", value: constant(2)}})
5555
require.Error(t, err)
5656
require.Contains(t, err.Error(), "wrong number of arguments")
5757
require.Contains(t, err.Error(), "given 2")
@@ -70,11 +70,125 @@ func TestContext_runFilter(t *testing.T) {
7070
return fmt.Sprintf("(%v, %v)", a, value), nil
7171
})
7272
ctx = NewContext(map[string]any{"x": 10}, cfg)
73-
out, err = ctx.ApplyFilter("closure", receiver, []valueFn{constant("x |add: y")})
73+
out, err = ctx.ApplyFilter("closure", receiver, []filterParam{{name: "", value: constant("x |add: y")}})
7474
require.NoError(t, err)
7575
require.Equal(t, "(self, 11)", out)
7676
}
7777

78+
// TestNamedFilterArguments tests filters with named arguments
79+
func TestNamedFilterArguments(t *testing.T) {
80+
cfg := NewConfig()
81+
constant := func(value any) valueFn {
82+
return func(Context) values.Value { return values.ValueOf(value) }
83+
}
84+
receiver := constant("image.jpg")
85+
86+
// Filter with named arguments
87+
cfg.AddFilter("img_url", func(image string, size string, opts map[string]any) string {
88+
scale := 1
89+
if s, ok := opts["scale"].(int); ok {
90+
scale = s
91+
}
92+
return fmt.Sprintf("img_url(%s, %s, scale=%d)", image, size, scale)
93+
})
94+
95+
ctx := NewContext(map[string]any{}, cfg)
96+
97+
// Test with positional and named arguments
98+
out, err := ctx.ApplyFilter("img_url", receiver, []filterParam{
99+
{name: "", value: constant("580x")},
100+
{name: "scale", value: constant(2)},
101+
})
102+
require.NoError(t, err)
103+
require.Equal(t, "img_url(image.jpg, 580x, scale=2)", out)
104+
105+
// Test with only positional argument (named args should be empty map)
106+
out, err = ctx.ApplyFilter("img_url", receiver, []filterParam{
107+
{name: "", value: constant("300x")},
108+
})
109+
require.NoError(t, err)
110+
require.Equal(t, "img_url(image.jpg, 300x, scale=1)", out)
111+
112+
// Test with multiple named arguments
113+
cfg.AddFilter("custom_filter", func(input string, opts map[string]any) string {
114+
format := opts["format"]
115+
name := opts["name"]
116+
return fmt.Sprintf("custom(%s, format=%v, name=%v)", input, format, name)
117+
})
118+
119+
out, err = ctx.ApplyFilter("custom_filter", receiver, []filterParam{
120+
{name: "format", value: constant("date")},
121+
{name: "name", value: constant("order.name")},
122+
})
123+
require.NoError(t, err)
124+
require.Equal(t, "custom(image.jpg, format=date, name=order.name)", out)
125+
126+
// Test mixing positional and named arguments
127+
cfg.AddFilter("mixed_args", func(input string, pos1 string, pos2 int, opts map[string]any) string {
128+
extra := ""
129+
if e, ok := opts["extra"].(string); ok {
130+
extra = e
131+
}
132+
return fmt.Sprintf("mixed(%s, %s, %d, extra=%s)", input, pos1, pos2, extra)
133+
})
134+
135+
out, err = ctx.ApplyFilter("mixed_args", receiver, []filterParam{
136+
{name: "", value: constant("arg1")},
137+
{name: "", value: constant(42)},
138+
{name: "extra", value: constant("bonus")},
139+
})
140+
require.NoError(t, err)
141+
require.Equal(t, "mixed(image.jpg, arg1, 42, extra=bonus)", out)
142+
}
143+
144+
// TestNamedFilterArgumentsParsing tests that named arguments are correctly parsed
145+
func TestNamedFilterArgumentsParsing(t *testing.T) {
146+
cfg := NewConfig()
147+
cfg.AddFilter("test_filter", func(input string, opts map[string]any) string {
148+
return fmt.Sprintf("input=%s, opts=%v", input, opts)
149+
})
150+
151+
// Test parsing filter with named arguments from expression string
152+
tests := []struct {
153+
name string
154+
expr string
155+
expected string
156+
}{
157+
{
158+
name: "single named argument",
159+
expr: "'test' | test_filter: scale: 2",
160+
expected: "input=test, opts=map[scale:2]",
161+
},
162+
{
163+
name: "multiple named arguments",
164+
expr: "'test' | test_filter: scale: 2, format: 'jpg'",
165+
expected: "input=test, opts=map[format:jpg scale:2]",
166+
},
167+
{
168+
name: "no arguments",
169+
expr: "'test' | test_filter",
170+
expected: "input=test, opts=map[]",
171+
},
172+
}
173+
174+
for _, tt := range tests {
175+
t.Run(tt.name, func(t *testing.T) {
176+
expr, err := Parse(tt.expr)
177+
require.NoError(t, err)
178+
ctx := NewContext(map[string]any{}, cfg)
179+
val, err := expr.Evaluate(ctx)
180+
require.NoError(t, err)
181+
// Check that the result contains the expected key parts
182+
result := fmt.Sprintf("%v", val)
183+
require.Contains(t, result, "input=test")
184+
if tt.name != "no arguments" {
185+
// For tests with named args, verify they're present
186+
require.Contains(t, result, "opts=map[")
187+
}
188+
})
189+
}
190+
}
191+
78192
// TestAddSafeFilterNilMap verifies that AddSafeFilter doesn't panic
79193
// when called on a Config with nil filters map
80194
func TestAddSafeFilterNilMap(t *testing.T) {

0 commit comments

Comments
 (0)