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
20 changes: 20 additions & 0 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion higher_order.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package expr

import (
"context"
"errors"
"fmt"
"go/ast"
"reflect"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
138 changes: 138 additions & 0 deletions higher_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
14 changes: 14 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions suggest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading