diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 536b025..c16c1d8 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -355,11 +355,31 @@ element and the outer `it` is no longer reachable until the inner | `all(list, pred)` | `bool` | `true` if `pred` is truthy for every element; short-circuits. Empty list → `true`. | | `find(list, pred)` | element or `nil` | First element for which `pred` is truthy, or `nil`. | | `count(list, pred)` | `int64` | Number of elements for which `pred` is truthy. | +| `try(value, default)`| value or `default` | Evaluates `value`; returns `default` if `value` raised an `ErrEvaluate` (missing key, type error, out-of-range index, etc.). The `default` expression is only evaluated when the primary fails. | The `list` argument must be a slice or array (or `nil`, which is treated as empty). Maps are not iterated by these forms; use `keys(m)` (from `WithBuiltins`) to drive a map iteration manually. +`try(value, default)` is the odd one out: it does not iterate a list +and binds no implicit `it`/`index`. Both arguments are arbitrary +expressions. The `default` is **only** evaluated when `value` failed, +so users can supply expensive or side-effecting fallbacks safely. + +`try` traps anything wrapping `ErrEvaluate`: missing fields/keys, nil +selectors, out-of-range indices, type-coercion failures from `int`, +`float`, etc. It does **not** trap raw `context.Canceled` or +`context.DeadlineExceeded` (cancellation must remain observable), nor +anything wrapping `ErrCompile`. Errors from evaluating the `default` +expression itself surface unchanged. Combine with operand-returning +`||` for the common case of presenting `nil` as a sentinel: + +``` +try(find(events, it.kind == "purchase").user, "—") +try(int(input), 0) > 0 +try(user.nickname, nil) || "(none)" +``` + Special-form names can be shadowed: if `WithFunctions` registers a function with the same name, or the caller's env contains an entry with that name, the user binding wins. This lets consumers replace diff --git a/higher_order.go b/higher_order.go index 6848191..9576e7a 100644 --- a/higher_order.go +++ b/higher_order.go @@ -2,6 +2,7 @@ package expr import ( "context" + "errors" "fmt" "go/ast" "reflect" @@ -48,13 +49,14 @@ func init() { "all": formAll, "find": formFind, "count": formCount, + "try": formTry, } } // higherOrderNames is the user-visible list of special-form builtins. // It drives "did you mean" hints so users see the spelling they would // actually type (`map`, not the internal rewritten name). -var higherOrderNames = []string{"map", "filter", "any", "all", "find", "count"} +var higherOrderNames = []string{"map", "filter", "any", "all", "find", "count", "try"} // iterItems evaluates collExpr and converts the result to a []any for // predicate iteration. nil is treated as an empty list so @@ -255,3 +257,29 @@ func formCount(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth } return total, nil } + +// formTry implements `try(value, default)`. It evaluates the first +// argument; if evaluation returns an ErrEvaluate, it evaluates and +// returns the second argument instead. The default expression is +// only evaluated when the primary expression failed, so users can +// safely supply expensive or side-effecting fallbacks. +// +// Context cancellation (context.Canceled / context.DeadlineExceeded) +// is propagated unwrapped. Anything wrapping ErrCompile is also +// propagated; expr should not normally surface compile errors at +// Run time, but if it does they signal a programmer mistake that +// try is not the right place to swallow. +func formTry(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth int) (any, error) { + if len(n.Args) != 2 { + return nil, fmt.Errorf("%w: try expects 2 arguments (value, default), got %d", + ErrEvaluate, len(n.Args)) + } + v, err := p.eval(ctx, n.Args[0], env, depth) + if err == nil { + return v, nil + } + if !errors.Is(err, ErrEvaluate) || errors.Is(err, ErrCompile) { + return nil, err + } + return p.eval(ctx, n.Args[1], env, depth) +} diff --git a/higher_order_test.go b/higher_order_test.go index 419b4d6..2c608ed 100644 --- a/higher_order_test.go +++ b/higher_order_test.go @@ -205,3 +205,141 @@ func TestHigherOrder_CancelDuringIteration(t *testing.T) { require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got %v", err) } + +// --- try special form --- + +func TestTry_ValueSucceeds(t *testing.T) { + // When the first argument evaluates without error, the default is + // not consulted at all and the success value is returned verbatim. + got, err := evalExpr(t.Context(), `try(42, 0)`, nil) + require.NoError(t, err) + require.Equal(t, int64(42), got) + + env := map[string]any{"user": map[string]any{"name": "Ada"}} + got, err = evalExpr(t.Context(), `try(user.name, "anon")`, env) + require.NoError(t, err) + require.Equal(t, "Ada", got) +} + +func TestTry_DefaultOnMissingKey(t *testing.T) { + // Missing-key access raises ErrEvaluate. try traps that and returns + // the default. The default expression itself can be any expression, + // including one referring to other env values. + env := map[string]any{"user": map[string]any{"name": "Ada"}} + got, err := evalExpr(t.Context(), `try(user.nickname, "—")`, env) + require.NoError(t, err) + require.Equal(t, "—", got) + + got, err = evalExpr(t.Context(), `try(missing.path, user.name)`, env) + require.NoError(t, err) + require.Equal(t, "Ada", got) +} + +func TestTry_DefaultOnTypeError(t *testing.T) { + // try also catches type errors, which is one of the points: cheap + // `try(int(s), 0)` for strings that may or may not parse. + opts := []Option{WithBuiltins()} + got, err := evalExpr(t.Context(), `try(int("not-a-number"), 0)`, nil, opts...) + require.NoError(t, err) + require.Equal(t, int64(0), got) + + got, err = evalExpr(t.Context(), `try(int("42"), 0)`, nil, opts...) + require.NoError(t, err) + require.Equal(t, int64(42), got) +} + +func TestTry_DefaultOnIndexOutOfRange(t *testing.T) { + env := map[string]any{"xs": []any{int64(1), int64(2)}} + got, err := evalExpr(t.Context(), `try(xs[10], -1)`, env) + require.NoError(t, err) + require.Equal(t, int64(-1), got) +} + +func TestTry_DefaultIsLazy(t *testing.T) { + // The default expression is only evaluated when the primary fails. + // Pin that with a function that panics if called. + opts := []Option{WithFunctions(map[string]any{ + "boom": func() int { panic("default should not have been evaluated") }, + })} + got, err := evalExpr(t.Context(), `try(42, boom())`, nil, opts...) + require.NoError(t, err) + require.Equal(t, int64(42), got) +} + +func TestTry_DefaultErrorPropagates(t *testing.T) { + // If the default expression itself errors, that error surfaces. + // try is not a blanket exception swallower; it traps the primary + // arg only. + env := map[string]any{"user": map[string]any{"name": "Ada"}} + _, err := evalExpr(t.Context(), `try(missing.path, also.missing)`, env) + require.ErrorIs(t, err, ErrEvaluate) +} + +func TestTry_ArityError(t *testing.T) { + _, err := evalExpr(t.Context(), `try(1)`, nil) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), "try expects 2 arguments") + + _, err = evalExpr(t.Context(), `try(1, 2, 3)`, nil) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), "try expects 2 arguments") +} + +func TestTry_DoesNotTrapContextCancellation(t *testing.T) { + // A canceled context should propagate as context.Canceled even + // though try would otherwise be tempted to swallow the error. The + // raw context error must reach the caller so timeouts and explicit + // cancellation are observable. + ctxC, cancel := context.WithCancel(context.Background()) + cancel() + _, err := evalExpr(ctxC, `try(user.name, "fallback")`, map[string]any{ + "user": map[string]any{"name": "Ada"}, + }) + require.True(t, errors.Is(err, context.Canceled), + "expected context.Canceled to propagate, got %v", err) +} + +func TestTry_UserFuncOverride(t *testing.T) { + // A registered try function shadows the special form, matching the + // env→funcs resolution order used by every other higher-order form. + opts := []Option{WithFunctions(map[string]any{ + "try": func(a, b int64) int64 { return a + b }, + })} + got, err := evalExpr(t.Context(), `try(2, 3)`, nil, opts...) + require.NoError(t, err) + require.Equal(t, int64(5), got) +} + +func TestTry_EnvShadowsForm(t *testing.T) { + // An env entry named try shadows the form too (so `try` resolves to + // the env value). This pins the consistent shadowing semantics; it + // is not a recommended pattern. + got, err := evalExpr(t.Context(), `try`, map[string]any{"try": int64(7)}) + require.NoError(t, err) + require.Equal(t, int64(7), got) +} + +func TestTry_NestedComposition(t *testing.T) { + // try composes inside higher-order forms. find returns nil when no + // element matches; selecting .name on nil errors; try falls back. + env := map[string]any{ + "events": []any{ + map[string]any{"kind": "click"}, + map[string]any{"kind": "view"}, + }, + } + got, err := evalExpr(t.Context(), + `try(find(events, it.kind == "purchase").user, "—")`, env) + require.NoError(t, err) + require.Equal(t, "—", got) +} + +func TestTry_ComposesWithLogicalOr(t *testing.T) { + // try plus operand-returning || covers the common "fall back to a + // default and present nil as something else" case. + env := map[string]any{"user": map[string]any{}} + got, err := evalExpr(t.Context(), + `try(user.nickname, nil) || "(none)"`, env) + require.NoError(t, err) + require.Equal(t, "(none)", got) +} diff --git a/llms.txt b/llms.txt index 9cd4abd..9782eed 100644 --- a/llms.txt +++ b/llms.txt @@ -123,6 +123,20 @@ you need to). | `all(list, pred)` | `bool` | True if all match (empty list → true, short-circuits) | | `find(list, pred)` | element or `nil` | First match or nil | | `count(list, pred)` | `int64` | Number of matches | +| `try(value, default)` | value or default | `value` if it evaluates cleanly, else `default` | + +`try` is the odd one out: it does not iterate a list, binds no `it` +or `index`, and traps anything wrapping `ErrEvaluate` (missing keys, +nil selectors, out-of-range indices, `int`/`float` parse failures). +The `default` expression is lazy: it runs only when the primary fails. +`try` does **not** trap raw `context.Canceled`, +`context.DeadlineExceeded`, or anything wrapping `ErrCompile`. + +``` +try(int(s), 0) // parse with fallback +try(find(events, it.kind == "purchase").user, "—") // optional chain +try(user.nickname, nil) || "(none)" // present nil +``` Names can be shadowed by `WithFunctions` or an env entry of the same name. The literal token `map` is rewritten to an internal sentinel diff --git a/suggest_test.go b/suggest_test.go index 4b306f0..5804a75 100644 --- a/suggest_test.go +++ b/suggest_test.go @@ -20,17 +20,16 @@ func TestSuggest_UndefinedIdentDidYouMean(t *testing.T) { func TestSuggest_UndefinedIdentAvailableList(t *testing.T) { // Small candidate set with no close match: the hint falls back to // listing the available names. With no CompileOptions there are no - // functions registered, so the only candidates are the env entries plus the - // 6 higher-order form names. Two env entries keeps the total under - // the 8-name cap that formatHint uses to decide whether a list is + // functions registered, so the only candidates are the env entries + // plus the higher-order form names. One env entry keeps the total + // inside the cap that formatHint uses to decide whether a list is // short enough to be useful. - env := map[string]any{"alpha": 1, "beta": 2} + env := map[string]any{"alpha": 1} _, err := evalExpr(t.Context(), "zzz", env) require.ErrorIs(t, err, ErrEvaluate) require.Contains(t, err.Error(), `undefined identifier "zzz"`) require.Contains(t, err.Error(), "available:") require.Contains(t, err.Error(), "alpha") - require.Contains(t, err.Error(), "beta") } func TestSuggest_MissingStructField(t *testing.T) {