Skip to content
Merged
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
49 changes: 32 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ out, err := tmpl.Render(ctx, env)

The text outside `${...}` is just text. The expression inside can use the same
selectors, functions, literals, and higher-order forms as any other compiled
expression.
expression. `nil` renders as the empty string; maps, slices, arrays, and
structs render as compact JSON (`${config}` produces `{"retries":3}`, not
`map[retries:3]`).

## Using the Go API

Expand Down Expand Up @@ -90,9 +92,11 @@ safe to share between goroutines. Compile at startup. Run per request.

No functions are registered by default, so the surface area is exactly as wide
as you want it. `WithBuiltins()` opts you into a small standard set (`len`,
`contains`, `has`, `keys`, `upper`, `lower`, `int`, `float`, `string`, `bool`,
`sprintf`). `WithFunctions` lets you register any Go function as a callable
identifier:
`contains`, `has`, `keys`, `entries`, `upper`, `lower`, `int`, `float`,
`string`, `bool`, `sprintf`). `entries(m)` returns the sorted key-value pairs
of a string-keyed map as `[{"key":k,"value":v}, ...]`, making maps iterable
through higher-order forms. `WithFunctions` lets you register any Go function
as a callable identifier:

```go
p, err := expr.Compile(`greet(upper(name))`, expr.WithFunctions(map[string]any{
Expand All @@ -105,8 +109,8 @@ Mix and match, or skip the builtins entirely and expose only the handful that
make sense for your sandbox. Opt-in groups — `expr.MathFuncs()` (`min`, `max`,
`abs`, `floor`, `ceil`, `round`), `expr.StringFuncs()` (`trim`, `split`,
`join`, `replace`, `startsWith`, `endsWith`), and `expr.CollectionFuncs()`
(`first`, `last`, `sum`, `slice`) — add the usual helpers via `WithFunctions`
without widening the default set.
(`first`, `last`, `sum`, `slice`, `sort`, `reverse`) — add the usual helpers
via `WithFunctions` without widening the default set.

## What the environment can be

Expand Down Expand Up @@ -143,22 +147,33 @@ are left alone, so nothing you already had stops working.

## Higher-order forms

A small set of always-available forms for working with lists: `map`, `filter`,
`any`, `all`, `find`, `count`. Inside the second argument, `it` is the current
element and `index` is its position:
A set of always-available forms for working with lists: `map`, `filter`,
`flatMap`, `any`, `all`, `find`, `count`, `sortBy`. Inside the body, `it` is
the current element and `index` is its position:

```go
p, err := expr.Compile(`filter(users, it.age >= 18 && index < 10)`)
```

The predicate is re-evaluated per element, so they compose naturally:
`any(orders, count(it.items, it.price > 100) > 0)`. Two more special forms
use laziness for control flow instead of iteration: `try(value, default)`
falls back when `value` errors, and `if(cond, then, else)` evaluates only the
branch the condition selects — so `if(n != 0, total/n, 0)` can't divide by
zero. These forms are always registered (no `WithBuiltins` needed), but you
can shadow any of them by registering a function or env value of the same
name.
Every iterating form also accepts a three-argument shape that names the element
explicitly, which makes nested forms readable and lets you reference an outer
element from inside an inner body:

```go
// Named bindings: r is the review, c is the comment.
map(reviews, r, map(r.comments, c, r.author + ": " + c))
```

`flatMap` works like `map` but splices list body results element-by-element
into the output, which flattens one level of nesting. `sortBy` evaluates a key
expression per element and returns a stable-sorted copy of the list.

Two more special forms use laziness for control flow instead of iteration:
`try(value, default)` falls back when `value` errors, and `if(cond, then,
else)` evaluates only the branch the condition selects, so `if(n != 0, total/n,
0)` can't divide by zero. These forms are always registered (no `WithBuiltins`
needed), but you can shadow any of them by registering a function or env value
of the same name.

## What it isn't

Expand Down
121 changes: 117 additions & 4 deletions builtin_groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"math"
"reflect"
"sort"
"strings"
)

Expand Down Expand Up @@ -63,12 +64,17 @@ func StringFuncs() map[string]any {
// slice(xs, i, j) elements [i, j) of a list, or the rune range
// of a string; negative indices count from the
// end and out-of-range bounds clamp
// sort(xs) ascending copy; all numbers (numeric order) or
// all strings (lexicographic); mixed types error
// reverse(xs) reversed copy; never mutates input
func CollectionFuncs() map[string]any {
return map[string]any{
"first": Func(nativeFirst),
"last": Func(nativeLast),
"sum": Func(nativeSum),
"slice": Func(nativeSlice),
"first": Func(nativeFirst),
"last": Func(nativeLast),
"sum": Func(nativeSum),
"slice": Func(nativeSlice),
"sort": Func(nativeSort),
"reverse": Func(nativeReverse),
}
}

Expand Down Expand Up @@ -399,3 +405,110 @@ func resolveIndex(i int64, n int) int {
}
return int(i)
}

func nativeSort(_ context.Context, args []any) (any, error) {
if err := checkArity("sort", 1, len(args)); err != nil {
return nil, err
}
return builtinSort(args[0])
}

// builtinSort returns a sorted copy of a list. All elements must be
// either numbers (any int/float mix, compared with the same rules as
// the < operator) or all strings (lexicographic). The sort is stable
// and reorders the original elements without converting them: ints
// stay ints, floats stay floats. Mixed or non-comparable element
// types produce an ErrEvaluate. An empty or nil input returns an
// empty []any.
func builtinSort(v any) ([]any, error) {
if v == nil {
return []any{}, nil
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return nil, fmt.Errorf("%w: sort: expected list, got %T", ErrEvaluate, v)
}
n := rv.Len()
out := make([]any, n)
for i := 0; i < n; i++ {
out[i] = rv.Index(i).Interface()
}
less, err := scalarLessFunc("sort", out)
if err != nil {
return nil, err
}
sort.SliceStable(out, less)
return out, nil
}

// scalarLessFunc validates that every value in vals is a number (any
// int/float mix) or that every value is a string, and returns the
// matching index-based less function. The mode is chosen by the first
// value; the error names the first value that does not fit. Shared by
// sort and sortBy so the two agree on comparison semantics.
func scalarLessFunc(name string, vals []any) (func(i, j int) bool, error) {
if len(vals) == 0 {
return func(i, j int) bool { return false }, nil
}
if _, ok := toFloat64(vals[0]); ok {
for i, v := range vals {
if _, ok := toFloat64(v); !ok {
return nil, fmt.Errorf("%w: %s: element %d is %T, not a number", ErrEvaluate, name, i, v)
}
}
return func(i, j int) bool { return numericLess(vals[i], vals[j]) }, nil
}
if _, ok := asString(vals[0]); ok {
for i, v := range vals {
if _, ok := asString(v); !ok {
return nil, fmt.Errorf("%w: %s: element %d is %T, not a string", ErrEvaluate, name, i, v)
}
}
return func(i, j int) bool {
a, _ := asString(vals[i])
b, _ := asString(vals[j])
return a < b
}, nil
}
return nil, fmt.Errorf("%w: %s: elements must be all numbers or all strings, got %T",
ErrEvaluate, name, vals[0])
}

// numericLess compares two numbers with the same rules as the <
// operator: both integral values compare as int64, any other mix
// compares as float64.
func numericLess(a, b any) bool {
if ai, ok := toInt64(a); ok {
if bi, ok := toInt64(b); ok {
return ai < bi
}
}
af, _ := toFloat64(a)
bf, _ := toFloat64(b)
return af < bf
}

func nativeReverse(_ context.Context, args []any) (any, error) {
if err := checkArity("reverse", 1, len(args)); err != nil {
return nil, err
}
return builtinReverse(args[0])
}

// builtinReverse returns a reversed copy of a list. It never mutates
// the input. nil and empty lists return an empty []any.
func builtinReverse(v any) ([]any, error) {
if v == nil {
return []any{}, nil
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return nil, fmt.Errorf("%w: reverse: expected list, got %T", ErrEvaluate, v)
}
n := rv.Len()
out := make([]any, n)
for i := 0; i < n; i++ {
out[i] = rv.Index(n - 1 - i).Interface()
}
return out, nil
}
36 changes: 36 additions & 0 deletions builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import (
// key presence for string-keyed maps
// has(m, k) true if map m has key k; errors if m is not a map
// keys(m) sorted string keys of a map
// entries(m) sorted key-value pairs of a string-keyed map; each
// element is map[string]any{"key": k, "value": v}
// lower(s), upper(s) case conversion
// sprintf(fmt, ...) fmt.Sprintf-style formatting with cycle guards
//
Expand All @@ -46,6 +48,7 @@ func Builtins() map[string]any {
"contains": Func(nativeContains),
"has": Func(nativeHas),
"keys": Func(nativeKeys),
"entries": Func(nativeEntries),
"lower": Func(nativeLower),
"upper": Func(nativeUpper),
"sprintf": Func(nativeSprintf),
Expand Down Expand Up @@ -116,6 +119,13 @@ func nativeKeys(_ context.Context, args []any) (any, error) {
return builtinKeys(args[0])
}

func nativeEntries(_ context.Context, args []any) (any, error) {
if err := checkArity("entries", 1, len(args)); err != nil {
return nil, err
}
return builtinEntries(args[0])
}

func nativeLower(_ context.Context, args []any) (any, error) {
if err := checkArity("lower", 1, len(args)); err != nil {
return nil, err
Expand Down Expand Up @@ -307,3 +317,29 @@ func builtinKeys(m any) ([]any, error) {
}
return out, nil
}

// builtinEntries returns the key-value pairs of a string-keyed map as a
// []any, sorted by key for determinism. Each element is a
// map[string]any{"key": k, "value": v}, mirroring the sort and key-type
// rules of builtinKeys.
func builtinEntries(m any) ([]any, error) {
if m == nil {
return nil, nil
}
rv := reflect.ValueOf(m)
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("%w: entries: expected map with string keys, got %T", ErrEvaluate, m)
}
mapKeys := rv.MapKeys()
strs := make([]string, len(mapKeys))
for i, k := range mapKeys {
strs[i] = k.String()
}
sort.Strings(strs)
out := make([]any, len(strs))
for i, s := range strs {
val := rv.MapIndex(mapStringKey(rv.Type().Key(), s)).Interface()
out[i] = map[string]any{"key": s, "value": val}
}
return out, nil
}
Loading
Loading