Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion expressions/builders.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion expressions/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions expressions/expressions.y
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func init() {
cyclefn func(string) Cycle
loop Loop
loopmods loopModifiers
filter_params []valueFn
filter_params []filterParam
}
%type<f> expr rel filtered cond
%type<filter_params> filter_params
Expand Down Expand Up @@ -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
Expand Down
55 changes: 50 additions & 5 deletions expressions/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand Down
122 changes: 118 additions & 4 deletions expressions/filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<self>", out)

Expand All @@ -43,15 +43,15 @@ 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)

// TODO optional argument
// 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")
Expand All @@ -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) {
Expand Down
Loading