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
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,14 @@ jobs:

- name: Test
run: go test -race -count=1 ./...

# Short fuzz runs catch pre-parse rewriter regressions that the
# seed corpora alone miss (e.g. `?` combined with literals).
- name: Fuzz smoke
run: |
go test -run='^$' -fuzz='^FuzzCompile$' -fuzztime=15s .
go test -run='^$' -fuzz='^FuzzEval$' -fuzztime=15s .
go test -run='^$' -fuzz='^FuzzTemplateParse$' -fuzztime=15s .
go test -run='^$' -fuzz='^FuzzTemplateEval$' -fuzztime=15s .
go test -run='^$' -fuzz='^FuzzRewrite$' -fuzztime=15s ./internal/jsonlit
go test -run='^$' -fuzz='^FuzzRewrite$' -fuzztime=15s ./internal/optaccess
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ parameter interpolation.

| File | Purpose |
| ------------------- | ------------------------------------------------------------------ |
| `engine.go` | `Compile`, `Option`, `map` keyword preprocessing |
| `engine.go` | `Compile`, `Option`, `map`/`if` keyword preprocessing |
| `program.go` | AST walker — the evaluator |
| `reflect.go` + `prepared.go` | Env lookup, function dispatch, cached signatures |
| `builtins.go` | Default function set exposed by `Builtins()` / `WithBuiltins()` |
| `higher_order.go` | `map`, `filter`, `any`, `all`, `find`, `count` special forms |
| `builtin_groups.go` | Opt-in `MathFuncs()` / `StringFuncs()` / `CollectionFuncs()` sets |
| `higher_order.go` | `map`, `filter`, `any`, `all`, `find`, `count`, `try`, `if` forms |
| `identifiers.go` | `Program.Identifiers()` — env-referenced name collection |
| `template.go` | `${...}` interpolation via `NewTemplate` / `Render` |
| `truthy.go` | `IsTruthy` rules used by `!`, `&&`, `\|\|`, `bool(v)` |
| `suggest.go` | "Did you mean…" for unknown identifiers |
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ p, err := expr.Compile(`greet(upper(name))`, expr.WithFunctions(map[string]any{
```

Mix and match, or skip the builtins entirely and expose only the handful that
make sense for your sandbox.
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.

## What the environment can be

Expand Down Expand Up @@ -148,9 +152,13 @@ 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)`. 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.
`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.

## What it isn't

Expand Down
53 changes: 32 additions & 21 deletions boundaries1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,21 +437,22 @@ func TestCall_UnknownFunction(t *testing.T) {
require.Contains(t, err.Error(), "unknown function")
}

// Registering a function with an unsupported signature fails at
// Compile time — the registration could never be called successfully,
// so surfacing it at load time beats hiding it until the first call.
func TestCall_TooManyReturns(t *testing.T) {
opts := []Option{WithFunctions(map[string]any{
_, err := Compile("three()", WithFunctions(map[string]any{
"three": func() (int, int, int) { return 1, 2, 3 },
})}
_, err := evalExpr(t.Context(), "three()", nil, opts...)
require.ErrorIs(t, err, ErrEvaluate)
}))
require.ErrorIs(t, err, ErrCompile)
require.Contains(t, err.Error(), "returns")
}

func TestCall_SecondReturnNotError(t *testing.T) {
opts := []Option{WithFunctions(map[string]any{
_, err := Compile("bad()", WithFunctions(map[string]any{
"bad": func() (int, string) { return 1, "x" },
})}
_, err := evalExpr(t.Context(), "bad()", nil, opts...)
require.ErrorIs(t, err, ErrEvaluate)
}))
require.ErrorIs(t, err, ErrCompile)
require.Contains(t, err.Error(), "second return must be error")
}

Expand Down Expand Up @@ -747,23 +748,33 @@ func TestBuiltin_If_Arity(t *testing.T) {
}
}

// Without WithBuiltins, `if(...)` must report a clean "unknown
// function" error using the user-visible name, not the internal
// rewrite sentinel.
// `if` is a special form like map/filter/try: always available, no
// WithBuiltins required.
func TestBuiltin_If_NotRegistered(t *testing.T) {
_, err := evalExpr(t.Context(), `if(true, 1, 2)`, nil)
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), `unknown function "if"`)
got, err := evalExpr(t.Context(), `if(true, 1, 2)`, nil)
require.NoError(t, err)
require.Equal(t, int64(1), got)
}

// `if` is eager: the unselected branch still evaluates, so a
// runtime error there propagates. Users who need laziness reach for
// try, &&, or ||.
func TestBuiltin_If_EagerEvaluation(t *testing.T) {
// `if` is lazy: only the branch selected by the condition evaluates,
// so the guard idiom protects against errors in the untaken branch.
func TestBuiltin_If_LazyEvaluation(t *testing.T) {
opts := []Option{WithBuiltins()}
env := map[string]any{"xs": []any{1, 2, 3}}
_, err := evalExpr(t.Context(), `if(true, xs[0], xs[99])`, env, opts...)
require.Error(t, err)
env := map[string]any{"xs": []any{int64(1), int64(2), int64(3)}}

got, err := evalExpr(t.Context(), `if(true, xs[0], xs[99])`, env, opts...)
require.NoError(t, err)
require.Equal(t, int64(1), got)

// The canonical division guard.
got, err = evalExpr(t.Context(), `if(n != 0, 10/n, 0)`, map[string]any{"n": int64(0)}, opts...)
require.NoError(t, err)
require.Equal(t, int64(0), got)

// An error in the *taken* branch still propagates.
_, err = evalExpr(t.Context(), `if(false, xs[0], xs[99])`, env, opts...)
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), "out of range")
}

// A user-registered `if` must shadow the builtin, matching the
Expand Down
101 changes: 101 additions & 0 deletions budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package expr

import (
"strings"
"sync"
"testing"

"github.com/deepnoodle-ai/expr/internal/require"
)

func TestBudget_UnderLimitSucceeds(t *testing.T) {
got, err := evalExpr(t.Context(), "1 + 2 * 3", nil, WithEvalBudget(100))
require.NoError(t, err)
require.Equal(t, int64(7), got)
}

func TestBudget_ExceededReturnsErrEvaluate(t *testing.T) {
env := map[string]any{"xs": make([]any, 1000)}
_, err := evalExpr(t.Context(), "map(xs, map(xs, map(xs, it)))", env, WithEvalBudget(10_000))
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), "evaluation budget exceeded")
}

// The budget is deterministic: the same program, env, and limit either
// always succeeds or always fails, independent of wall-clock speed.
func TestBudget_Deterministic(t *testing.T) {
env := map[string]any{"xs": []any{int64(1), int64(2), int64(3)}}
p, err := Compile("count(xs, it > 1)", WithEvalBudget(5))
require.NoError(t, err)
for i := 0; i < 10; i++ {
_, err := p.Run(t.Context(), env)
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), "evaluation budget exceeded")
}
}

// Each Run gets the full budget; a Run that consumed most of the
// budget must not starve the next one.
func TestBudget_PerRun(t *testing.T) {
env := map[string]any{"xs": []any{int64(1), int64(2), int64(3)}}
p, err := Compile("count(xs, it > 1)", WithEvalBudget(100))
require.NoError(t, err)
for i := 0; i < 5; i++ {
got, err := p.Run(t.Context(), env)
require.NoError(t, err)
require.Equal(t, int64(2), got)
}
}

func TestBudget_ConcurrentRunsIndependent(t *testing.T) {
env := map[string]any{"xs": []any{int64(1), int64(2), int64(3)}}
p, err := Compile("count(xs, it > 1)", WithEvalBudget(100))
require.NoError(t, err)
var wg sync.WaitGroup
errs := make([]error, 16)
for i := range errs {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, errs[i] = p.Run(t.Context(), env)
}(i)
}
wg.Wait()
for _, err := range errs {
require.NoError(t, err)
}
}

// try cannot be used to escape the budget: once exhausted, every
// subsequent node evaluation (including try's fallback) fails too.
func TestBudget_TryDoesNotEscape(t *testing.T) {
env := map[string]any{"xs": make([]any, 1000)}
_, err := evalExpr(t.Context(), "try(map(xs, map(xs, it)), 42)", env, WithEvalBudget(100))
require.ErrorIs(t, err, ErrEvaluate)
require.Contains(t, err.Error(), "evaluation budget exceeded")
}

func TestBudget_ZeroAndNegativeMeanUnlimited(t *testing.T) {
env := map[string]any{"xs": make([]any, 100)}
for _, n := range []int{0, -1} {
got, err := evalExpr(t.Context(), "len(map(xs, it))", env, WithBuiltins(), WithEvalBudget(n))
require.NoError(t, err)
require.Equal(t, 100, got)
}
}

// A budget on a template bounds each placeholder expression.
func TestBudget_Template(t *testing.T) {
tpl, err := NewTemplate("total: ${count(xs, it > 0)}", WithEvalBudget(10))
require.NoError(t, err)
xs := make([]any, 1000)
for i := range xs {
xs[i] = int64(i)
}
env := map[string]any{"xs": xs}
_, err = tpl.Render(t.Context(), env)
require.Error(t, err)
if !strings.Contains(err.Error(), "evaluation budget exceeded") {
t.Fatalf("expected budget error, got: %v", err)
}
}
Loading
Loading