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
2 changes: 1 addition & 1 deletion docs/guides/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ composite literal.
"views": count(events, it.kind == "view"),
"purchases": count(events, it.kind == "purchase"),
"has_sale": any(events, it.kind == "purchase"),
"top_user": find(events, it.kind == "purchase").user,
"top_user": find(events, it.kind == "purchase")?.user,
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/higher-order-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ a list:
"views": count(events, it.kind == "view"),
"purchases": count(events, it.kind == "purchase"),
"has_sale": any(events, it.kind == "purchase"),
"top_user": find(events, it.kind == "purchase").user,
"top_user": find(events, it.kind == "purchase")?.user,
}
```

Expand Down
37 changes: 36 additions & 1 deletion docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ 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(find(events, it.kind == "purchase")?.user, "—")
try(int(input), 0) > 0
try(user.nickname, nil) || "(none)"
```
Expand All @@ -408,6 +408,41 @@ internal token before parsing so the form can still be called as
`map(xs, it * 2)`, and translates it back for error messages and
method lookups.

## Optional access (`?.` and `?[`)

`?.field` and `?[idx]` are pre-parse rewrites for "look this up, but
return `nil` if the receiver is missing or the lookup falls off the
end." They cover the common case where a JSON-shaped env may or may
not include a particular branch, without forcing the user to wrap
every access in `try(...)`.

```
user?.profile?.nickname || "(none)"
events?[0]?.user
config?.feature?.enabled
```

The semantics:

- If the receiver is `nil`, the result is `nil` and the right-hand
side is not consulted.
- For `?.`, a missing struct field or absent map key resolves to
`nil`.
- For `?[`, a missing map key or an out-of-range slice/string index
resolves to `nil`.
- A wrong-kind error (selecting on a value that is not a struct or
map, indexing a slice with a non-integer, indexing into a map with
the wrong key type) still surfaces as `ErrEvaluate`. `?.` and `?[`
swallow "not there" errors, not "real bugs."

`?.` and `?[` are pure source-level sugar. The rewrite happens
before the parser sees the source, so they behave like calls on
internal sentinel functions (`__try_select__` and `__try_index__`).
Users do not interact with those names directly, but they may
appear in error chains for diagnostic purposes. Strings, runes, and
comments are not rewritten — `?.` written inside `"..."` or a
comment is preserved verbatim.

## Helpful errors

expr annotates "not found" errors with a short hint drawn from the
Expand Down
2 changes: 1 addition & 1 deletion docs_examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func TestDocsExample4_EventSummary(t *testing.T) {
"views": count(events, it.kind == "view"),
"purchases": count(events, it.kind == "purchase"),
"has_sale": any(events, it.kind == "purchase"),
"top_user": find(events, it.kind == "purchase").user,
"top_user": find(events, it.kind == "purchase")?.user,
}`
env := map[string]any{
"events": []any{
Expand Down
20 changes: 19 additions & 1 deletion engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"strings"

"github.com/deepnoodle-ai/expr/internal/jsonlit"
"github.com/deepnoodle-ai/expr/internal/optaccess"
)

// ErrCompile wraps parse failures so callers can match with errors.Is.
Expand Down Expand Up @@ -151,7 +152,12 @@ func Compile(code string, opts ...Option) (*Program, error) {
for _, opt := range opts {
opt(cfg)
}
parsed := preprocessSource(jsonlit.Rewrite(code))
// Pipeline order: optaccess turns `?.`/`?[` into sentinel calls
// while the source still uses raw operator syntax; jsonlit then
// rewrites bare composite literals; preprocessSource handles the
// keyword rewrites (`map`) so the parser accepts them as
// identifiers.
parsed := preprocessSource(jsonlit.Rewrite(optaccess.Rewrite(code)))
fset := token.NewFileSet()
node, err := parser.ParseExprFrom(fset, "", parsed, 0)
if err != nil {
Expand Down Expand Up @@ -187,6 +193,18 @@ const mapFormName = "__expr_map__"
// in error messages and method lookups.
const ifFuncName = "__expr_if__"

// trySelectFormName and tryIndexFormName are the internal sentinel
// identifiers emitted by the optaccess pre-parse rewrite for the
// optional-access operators `?.` and `?[`. The evaluator dispatches
// them as special forms in higherOrderForms so they can short-circuit
// on a nil receiver and treat missing fields / out-of-range indices
// as nil rather than as errors. Users never type these names
// directly.
const (
trySelectFormName = "__try_select__"
tryIndexFormName = "__try_index__"
)

// keywordRewrites lists the Go keyword tokens that expr accepts as
// ordinary identifiers. Each entry maps the source spelling to its
// internal sentinel; preprocessSource walks tokens and substitutes
Expand Down
11 changes: 11 additions & 0 deletions fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ var fuzzCorpus = []string{
"1 ^ 2",
"1i",
"",
// optional access (`?.` / `?[`)
"state.user?.nickname",
"state.user?.profile?.nickname",
"state.items?[0]",
"state.items?[99]",
"state.user?.nickname || \"(none)\"",
`"contains?.field"`,
"state /* ?.x */ .name",
"?.x",
"a?.",
"a?[",
}

// fuzzEnv is the environment FuzzEval runs every mutated expression
Expand Down
169 changes: 169 additions & 0 deletions higher_order.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ func init() {
"find": formFind,
"count": formCount,
"try": formTry,
// Sentinel forms emitted by the optaccess pre-parse rewrite.
// Users do not type these names; they appear only as the
// callee of synthesized CallExpr nodes.
trySelectFormName: formTrySelect,
tryIndexFormName: formTryIndex,
}
}

Expand Down Expand Up @@ -308,6 +313,170 @@ func formCount(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth
return total, nil
}

// formTrySelect implements the optaccess `?.` rewrite target,
// `__try_select__(receiver, "field")`. It evaluates the receiver;
// when the receiver is nil, returns nil. Otherwise it performs a
// field/key lookup via trySelectName, which returns nil for missing
// fields or keys without producing an error. Type errors (e.g.,
// selecting on a non-struct, non-map value) propagate so real bugs
// still surface.
func formTrySelect(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth int) (any, error) {
if len(n.Args) != 2 {
return nil, fmt.Errorf("%w: %s expects 2 arguments (receiver, field), got %d",
ErrEvaluate, trySelectFormName, len(n.Args))
}
recv, err := p.eval(ctx, n.Args[0], env, depth)
if err != nil {
return nil, err
}
if recv == nil {
return nil, nil
}
name, err := p.eval(ctx, n.Args[1], env, depth)
if err != nil {
return nil, err
}
s, ok := name.(string)
if !ok {
return nil, fmt.Errorf("%w: %s expects a string field name, got %T",
ErrEvaluate, trySelectFormName, name)
}
return trySelectName(recv, s, p.fieldTags)
}

// formTryIndex implements the optaccess `?[` rewrite target,
// `__try_index__(receiver, idx)`. Mirrors formTrySelect: nil
// receiver returns nil, missing keys and out-of-range indices return
// nil, and type errors (wrong index kind for the receiver) propagate.
func formTryIndex(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth int) (any, error) {
if len(n.Args) != 2 {
return nil, fmt.Errorf("%w: %s expects 2 arguments (receiver, index), got %d",
ErrEvaluate, tryIndexFormName, len(n.Args))
}
recv, err := p.eval(ctx, n.Args[0], env, depth)
if err != nil {
return nil, err
}
if recv == nil {
return nil, nil
}
idx, err := p.eval(ctx, n.Args[1], env, depth)
if err != nil {
return nil, err
}
return tryIndexValue(recv, idx)
}

// trySelectName mirrors selectField but returns nil for missing
// fields or keys. Method values are not surfaced; selectField does
// not surface them either, so the two paths agree on what counts as
// a "field" lookup.
func trySelectName(recv any, name string, fieldTags *structTagConfig) (any, error) {
if recv == nil {
return nil, nil
}
if m, ok := recv.(map[string]any); ok {
v := m[name]
return v, nil
}
rv := reflect.ValueOf(recv)
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil, nil
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Struct:
fv, ok, err := structFieldByName(rv, name, fieldTags)
if err != nil {
return nil, err
}
if !ok || !fv.IsValid() || !fv.CanInterface() {
return nil, nil
}
return fv.Interface(), nil
case reflect.Map:
if rv.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("%w: cannot select %q on map with non-string keys",
ErrEvaluate, name)
}
mv := rv.MapIndex(mapStringKey(rv.Type().Key(), name))
if !mv.IsValid() {
return nil, nil
}
return mv.Interface(), nil
}
return nil, fmt.Errorf("%w: cannot select %q on %T", ErrEvaluate, name, recv)
}

// tryIndexValue mirrors indexValue but returns nil for missing
// keys and out-of-range slice/string indices. Wrong-kind errors
// (e.g., string index into a slice, non-integer slice index) still
// propagate.
func tryIndexValue(recv, idx any) (any, error) {
if recv == nil {
return nil, nil
}
if m, ok := recv.(map[string]any); ok {
key, ok := idx.(string)
if !ok {
return nil, fmt.Errorf("%w: map index must be string, got %T",
ErrEvaluate, idx)
}
v := m[key]
return v, nil
}
rv := reflect.ValueOf(recv)
switch rv.Kind() {
case reflect.Slice, reflect.Array:
i, err := toIndexInt(idx)
if err != nil {
return nil, err
}
if i < 0 || i >= int64(rv.Len()) {
return nil, nil
}
return rv.Index(int(i)).Interface(), nil
case reflect.String:
i, err := toIndexInt(idx)
if err != nil {
return nil, err
}
runes := []rune(rv.String())
if i < 0 || i >= int64(len(runes)) {
return nil, nil
}
return string(runes[i]), nil
case reflect.Map:
keyType := rv.Type().Key()
if idx == nil {
return nil, fmt.Errorf("%w: cannot use nil as map key %v", ErrEvaluate, keyType)
}
kv := reflect.ValueOf(idx)
if !kv.Type().AssignableTo(keyType) {
if isNumericKind(keyType.Kind()) && isNumericKind(kv.Kind()) {
converted, err := safeNumericConvert(kv, keyType)
if err != nil {
return nil, fmt.Errorf("%w: map key conversion: %v", ErrEvaluate, err)
}
kv = converted
} else if kv.Type().ConvertibleTo(keyType) {
kv = kv.Convert(keyType)
} else {
return nil, fmt.Errorf("%w: cannot use %T as map key %v",
ErrEvaluate, idx, keyType)
}
}
mv := rv.MapIndex(kv)
if !mv.IsValid() {
return nil, nil
}
return mv.Interface(), nil
}
return nil, fmt.Errorf("%w: cannot index %T", ErrEvaluate, recv)
}

// 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
Expand Down
Loading
Loading