diff --git a/boundaries1_test.go b/boundaries1_test.go index 6be4ee1..80c65d8 100644 --- a/boundaries1_test.go +++ b/boundaries1_test.go @@ -387,12 +387,10 @@ func TestCall_OnNilSelectorReceiver(t *testing.T) { func TestCall_UnsupportedCallTarget(t *testing.T) { // Call target is an index expression, which expr does not support - // as a callable (only identifiers and selectors are). - env := map[string]any{ - "fns": []any{func() int64 { return 1 }}, - } - _, err := evalExpr(t.Context(), "fns[0]()", env) - require.ErrorIs(t, err, ErrEvaluate) + // as a callable (only identifiers and selectors are). Rejected at + // Compile time so the mistake surfaces before any Run. + _, err := Compile("fns[0]()") + require.ErrorIs(t, err, ErrCompile) require.Contains(t, err.Error(), "call target") } diff --git a/boundaries2_test.go b/boundaries2_test.go index 14f63a1..bc9615c 100644 --- a/boundaries2_test.go +++ b/boundaries2_test.go @@ -373,17 +373,26 @@ func TestAdversarial_AnyNilFunctionViaSelector(t *testing.T) { // --- 17. Constant-folded MinInt64 negation --- func TestAdversarial_FoldedNegMinInt(t *testing.T) { - // At compile time, the parser yields a UnaryExpr SUB of a literal - // 9223372036854775808 — which is too big for int64. Compile should - // surface the literal-out-of-range error or correctly produce - // MinInt64. Should not panic. - defer func() { - if r := recover(); r != nil { - t.Fatalf("compile -9223372036854775808 panicked: %v", r) - } - }() - v, err := evalExpr(t.Context(), "-9223372036854775808", nil) - t.Logf("-9223372036854775808 => %v, err=%v", v, err) + // The parser yields a UnaryExpr SUB of a literal + // 9223372036854775808 — which is too big for int64 on its own. + // The fold pass reads the negation as a single signed literal, so + // MinInt64 is representable the way users write it. + cases := []struct { + expr string + want int64 + }{ + {"-9223372036854775808", math.MinInt64}, + {"-0x8000000000000000", math.MinInt64}, + {"-9223372036854775808 + 1", math.MinInt64 + 1}, + } + for _, tc := range cases { + v, err := evalExpr(t.Context(), tc.expr, nil) + require.NoError(t, err) + require.Equal(t, tc.want, v) + } + // One past MinInt64 is still out of range and must error, not wrap. + _, err := evalExpr(t.Context(), "-9223372036854775809", nil) + require.ErrorIs(t, err, ErrEvaluate) } // --- 18. Self-referential map via env doesn't infinite loop in keys() --- @@ -454,11 +463,9 @@ func TestAdversarial_MulOverflow(t *testing.T) { "a": int64(math.MaxInt64), "b": int64(2), } - got, err := evalExpr(t.Context(), "a * b", env) - t.Logf("MaxInt64 * 2 => %v, err=%v", got, err) - if err == nil && got == int64(-2) { - t.Logf("CONFIRMED: silent integer overflow on multiply") - } + _, err := evalExpr(t.Context(), "a * b", env) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), "integer overflow") } // --- 24. Addition overflow --- @@ -468,11 +475,9 @@ func TestAdversarial_AddOverflow(t *testing.T) { "a": int64(math.MaxInt64), "b": int64(1), } - got, err := evalExpr(t.Context(), "a + b", env) - t.Logf("MaxInt64 + 1 => %v, err=%v", got, err) - if err == nil && got == int64(math.MinInt64) { - t.Logf("CONFIRMED: silent integer overflow on add") - } + _, err := evalExpr(t.Context(), "a + b", env) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), "integer overflow") } // --- 25. Method-value invocation panic from nil pointer with pointer receiver --- diff --git a/docs/reference/spec.md b/docs/reference/spec.md index b9cfd87..f0531ec 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -27,6 +27,9 @@ accepted is exactly the subset of `ast.Expr` listed in - All integer literals become `int64`. Values outside the `int64` range return an `ErrEvaluate` at Run time, not at Compile time — the parser accepts them, but evaluation fails. +- A negated literal is read as a single signed value, so + `-9223372036854775808` (`MinInt64`) evaluates fine even though the + bare digits overflow on their own. ### Floating-point literals @@ -76,8 +79,9 @@ group expressions as usual. Go's bitwise operators (`&`, `|`, `^`, `<<`, concatenation. Integer `/` and `%` by zero return `ErrEvaluate`. - Any mix of int and float promotes both to `float64`. `%` on floats uses `math.Mod`. Float `/` and `%` by zero return `ErrEvaluate`. -- Integer overflow wraps (matching Go). `-MinInt64` and `MinInt64 / -1` - wrap silently to `MinInt64`; they do not panic. +- Integer arithmetic is overflow-checked: `+`, `-`, and `*` whose exact + result does not fit in `int64` return `ErrEvaluate`, as do `-MinInt64` + and `MinInt64 / -1`. Nothing wraps silently and nothing panics. - `+` on any other type combination is an error. ### Comparison (`== != < <= >= >`) @@ -242,7 +246,8 @@ The callable is resolved in order: 2. If the target is a selector `x.f`, expr evaluates `x` and then looks for a method, struct field, or map entry named `f` on it. 3. Any other call target (index expression, call expression, paren - expression) returns `ErrEvaluate: unsupported call target`. + expression, optional access) is rejected at Compile time with + `ErrCompile: call target must be a function name or selector`. ### Method resolution order (for selector calls) @@ -435,6 +440,13 @@ The semantics: the wrong key type) still surfaces as `ErrEvaluate`. `?.` and `?[` swallow "not there" errors, not "real bugs." +The receiver can be any primary expression: an identifier, a selector +or index chain, a call (including `map(...)` and `if(...)`), a +parenthesized group, or an array/object literal (`[1, 2]?[0]`, +`{"a": 1}?.a`). `?.map` and `?.if` work the same way `.map` and +`.if` do. Calling the result of an optional access (`a?.b()`) is not +supported and is rejected at Compile time. + `?.` 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__`). diff --git a/higher_order.go b/higher_order.go index 7c3522b..120d762 100644 --- a/higher_order.go +++ b/higher_order.go @@ -9,6 +9,7 @@ import ( "go/printer" "go/token" "reflect" + "strconv" "strings" ) @@ -185,21 +186,159 @@ func wrapPredicateErr(name string, predicate ast.Expr, index int, err error) err } // formatPredicate prints a predicate AST back to source text for -// inclusion in error messages. The internal map sentinel is -// translated back to `map` so nested forms read the way users wrote -// them. Returns "" if printing fails or if the result contains a -// backtick (which would clash with the surrounding error format), +// inclusion in error messages. Internal sentinels are translated +// back to the syntax the user wrote: keyword idents (`map`, `if`) +// recover their names, optional-access sentinel calls print as +// `recv?.field` / `recv?[idx]`, and `[]any{...}` / +// `map[string]any{...}` composite literals print in the JSON style +// users type. Returns "" if printing fails or if the result contains +// a backtick (which would clash with the surrounding error format), // letting wrapPredicateErr fall back to a position-only message. func formatPredicate(node ast.Expr) string { - var buf bytes.Buffer - if err := printer.Fprint(&buf, token.NewFileSet(), node); err != nil { + out := exprDisplayString(node) + if out == "" || strings.ContainsRune(out, '`') { return "" } - out := strings.ReplaceAll(buf.String(), mapFormName, "map") - if strings.ContainsRune(out, '`') { + return out +} + +// exprDisplayString prints node via go/printer after rewriting it +// into user-facing display form. Returns "" when printing fails. +func exprDisplayString(node ast.Expr) string { + var buf bytes.Buffer + if err := printer.Fprint(&buf, token.NewFileSet(), displayExpr(node)); err != nil { return "" } - return out + return buf.String() +} + +// displayExpr returns a copy of node with internal sentinel forms +// translated back to user-visible syntax. The original tree is never +// mutated — it is shared by concurrent Runs. Constructs the printer +// cannot represent directly (`?.`, `?[`, JSON-style literals) are +// smuggled through as synthetic Idents whose Name carries the exact +// display text; go/printer emits Ident names verbatim, and every +// receiver position they can occupy is a primary expression, so no +// precedence parentheses are lost. +func displayExpr(node ast.Expr) ast.Expr { + switch n := node.(type) { + case *ast.Ident: + if d := displayIdent(n.Name); d != n.Name { + return &ast.Ident{Name: d} + } + return &ast.Ident{Name: n.Name} + case *ast.BasicLit: + return &ast.BasicLit{Kind: n.Kind, Value: n.Value} + case *ast.ParenExpr: + return &ast.ParenExpr{X: displayExpr(n.X)} + case *ast.UnaryExpr: + return &ast.UnaryExpr{Op: n.Op, X: displayExpr(n.X)} + case *ast.BinaryExpr: + return &ast.BinaryExpr{X: displayExpr(n.X), Op: n.Op, Y: displayExpr(n.Y)} + case *ast.SelectorExpr: + sel, _ := displayExpr(n.Sel).(*ast.Ident) + if sel == nil { + sel = n.Sel + } + return &ast.SelectorExpr{X: displayExpr(n.X), Sel: sel} + case *ast.IndexExpr: + return &ast.IndexExpr{X: displayExpr(n.X), Index: displayExpr(n.Index)} + case *ast.CallExpr: + if out, ok := displayOptAccess(n); ok { + return out + } + args := make([]ast.Expr, len(n.Args)) + for i, a := range n.Args { + args[i] = displayExpr(a) + } + return &ast.CallExpr{Fun: displayExpr(n.Fun), Args: args} + case *ast.CompositeLit: + if out, ok := displayJSONLit(n); ok { + return out + } + } + return node +} + +// displayOptAccess turns the optaccess sentinel calls back into the +// `recv?.field` / `recv?[idx]` source forms, carried as a synthetic +// Ident (see displayExpr). ok is false when the call does not match +// the exact shape the rewrite emits. +func displayOptAccess(n *ast.CallExpr) (ast.Expr, bool) { + ident, ok := n.Fun.(*ast.Ident) + if !ok || len(n.Args) != 2 { + return nil, false + } + switch ident.Name { + case trySelectFormName: + lit, ok := n.Args[1].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return nil, false + } + field, err := strconv.Unquote(lit.Value) + if err != nil { + return nil, false + } + recv := exprDisplayString(n.Args[0]) + if recv == "" { + return nil, false + } + return &ast.Ident{Name: recv + "?." + field}, true + case tryIndexFormName: + recv := exprDisplayString(n.Args[0]) + idx := exprDisplayString(n.Args[1]) + if recv == "" || idx == "" { + return nil, false + } + return &ast.Ident{Name: recv + "?[" + idx + "]"}, true + } + return nil, false +} + +// displayJSONLit prints []any{...} / map[string]any{...} composite +// literals in the bare JSON style expr accepts in source, carried as +// a synthetic Ident (see displayExpr). ok is false for any other +// composite shape. +func displayJSONLit(n *ast.CompositeLit) (ast.Expr, bool) { + switch typ := n.Type.(type) { + case *ast.ArrayType: + if typ.Len != nil { + return nil, false + } + if ident, ok := typ.Elt.(*ast.Ident); !ok || ident.Name != "any" { + return nil, false + } + parts := make([]string, len(n.Elts)) + for i, e := range n.Elts { + s := exprDisplayString(e) + if s == "" { + return nil, false + } + parts[i] = s + } + return &ast.Ident{Name: "[" + strings.Join(parts, ", ") + "]"}, true + case *ast.MapType: + kIdent, kOk := typ.Key.(*ast.Ident) + vIdent, vOk := typ.Value.(*ast.Ident) + if !kOk || !vOk || kIdent.Name != "string" || vIdent.Name != "any" { + return nil, false + } + parts := make([]string, len(n.Elts)) + for i, e := range n.Elts { + kv, ok := e.(*ast.KeyValueExpr) + if !ok { + return nil, false + } + k := exprDisplayString(kv.Key) + v := exprDisplayString(kv.Value) + if k == "" || v == "" { + return nil, false + } + parts[i] = k + ": " + v + } + return &ast.Ident{Name: "{" + strings.Join(parts, ", ") + "}"}, true + } + return nil, false } func formMap(p *Program, ctx context.Context, n *ast.CallExpr, env any, depth int) (any, error) { diff --git a/higher_order_test.go b/higher_order_test.go index a2566c5..bdf676b 100644 --- a/higher_order_test.go +++ b/higher_order_test.go @@ -214,6 +214,38 @@ func TestHigherOrder_PredicateErrorNestedForms(t *testing.T) { "map sentinel leaked into error: %s", err.Error()) } +// Predicate source in error messages reads the way the user typed +// it: keyword sentinels print as `if`, optional-access sentinels +// print as `?.` / `?[`, JSON-style literals keep their bare form, +// and string literals are never rewritten — even when their content +// happens to spell an internal sentinel name. +func TestHigherOrder_PredicateErrorDisplaySyntax(t *testing.T) { + cases := []struct { + expr string + want string + }{ + {`filter(items, if(it, 1, 2) > "x")`, "filter predicate `if(it, 1, 2) > \"x\"` failed"}, + {`filter(items, it?.name + 1)`, "filter predicate `it?.name + 1` failed"}, + {`filter(items, it?[0] + "x")`, "filter predicate `it?[0] + \"x\"` failed"}, + {`map(items, [it, 2] == "x")`, "map predicate `[it, 2] == \"x\"` failed"}, + {`map(items, {"k": it} + 1)`, "map predicate `{\"k\": it} + 1` failed"}, + {`map(items, "__expr_map__" + nil)`, "map predicate `\"__expr_map__\" + nil` failed"}, + } + env := map[string]any{"items": []any{int64(1)}} + for _, tc := range cases { + t.Run(tc.expr, func(t *testing.T) { + _, err := evalExpr(t.Context(), tc.expr, env, WithBuiltins()) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), tc.want) + for _, sentinel := range []string{ifFuncName, trySelectFormName, tryIndexFormName, "map[string]any"} { + if strings.Contains(err.Error(), sentinel) { + t.Fatalf("internal form %q leaked into error: %s", sentinel, err.Error()) + } + } + }) + } +} + // Each form name appears in its own wrapping. Spot-check the names // rather than enumerating: filter, find, count, any, all all flow // through the same forEach so a single fixture per name suffices. diff --git a/internal/optaccess/optaccess.go b/internal/optaccess/optaccess.go index cb21d63..7912cbb 100644 --- a/internal/optaccess/optaccess.go +++ b/internal/optaccess/optaccess.go @@ -12,9 +12,11 @@ // skipped entirely. // // The LHS of `?.` / `?[` is the primary expression that ends just -// before the `?`. The walker matches balanced parens and brackets so -// `f(a)?.b`, `a[0]?.b`, and `(a + b)?.c` all rewrite with the -// expected LHS. Chained optional access (`a?.b?.c`) is processed by +// before the `?`. The walker matches balanced parens, brackets, and +// braces so `f(a)?.b`, `a[0]?.b`, `(a + b)?.c`, `[1, 2]?[0]`, and +// `{"k": 1}?.k` all rewrite with the expected LHS, as do calls on +// the `map` and `if` keywords that expr accepts as call targets. +// Chained optional access (`a?.b?.c`) is processed by // repeated single-rewrite passes until the source no longer contains // `?.` / `?[`, producing nested calls like // `__try_select__(__try_select__(a, "b"), "c")`. @@ -69,11 +71,11 @@ func rewriteOnce(src string) (string, bool) { next := toks[i+1] switch next.kind { case token.PERIOD: - if i+2 >= len(toks) || toks[i+2].kind != token.IDENT { + if i+2 >= len(toks) || !isFieldToken(toks[i+2].kind) { continue } lhsStart := lhsStartIdx(toks, src, i) - if lhsStart < 0 { + if lhsStart < 0 || lhsStart >= i { continue } field := toks[i+2].lit @@ -89,7 +91,7 @@ func rewriteOnce(src string) (string, bool) { continue } lhsStart := lhsStartIdx(toks, src, i) - if lhsStart < 0 { + if lhsStart < 0 || lhsStart >= i { continue } lhsBytes := src[toks[lhsStart].pos:toks[i].pos] @@ -154,6 +156,20 @@ func isQuestion(src string, t tokenInfo) bool { return t.kind == token.ILLEGAL && t.end-t.pos == 1 && src[t.pos] == '?' } +// isFieldToken reports whether t can be the field name after `?.`. +// Plain identifiers are the normal case; `map` and `if` are accepted +// too because expr lets those keywords act as ordinary identifiers +// (the engine rewrites `.map` / `.if` selectors on the non-optional +// path), and the field name is spliced into a string literal here, so +// no keyword rewrite is needed downstream. +func isFieldToken(t token.Token) bool { + switch t { + case token.IDENT, token.MAP, token.IF: + return true + } + return false +} + // matchBracketForward finds the index of the `]` that closes the `[` // at lbrack. Returns -1 if no matching close exists. func matchBracketForward(toks []tokenInfo, lbrack int) int { @@ -188,7 +204,10 @@ func lhsStartIdx(toks []tokenInfo, src string, qIdx int) int { for i >= 0 { t := toks[i] switch t.kind { - case token.IDENT, token.INT, token.FLOAT, token.STRING, token.CHAR: + case token.IDENT, token.INT, token.FLOAT, token.STRING, token.CHAR, + token.MAP, token.IF: + // `map` and `if` walk like identifiers: expr accepts them + // as ordinary names in selector chains (`a.map?.x`). if i-1 >= 0 && toks[i-1].kind == token.PERIOD { i -= 2 continue @@ -199,8 +218,29 @@ func lhsStartIdx(toks []tokenInfo, src string, qIdx int) int { if j < 0 { return -1 } + if j == 0 { + // The bracket pair opens the input, so it can only be + // an array literal: `[1, 2]?[0]`. + return 0 + } i = j - 1 continue + case token.RBRACE: + // Brace-closed primary: a bare object literal `{...}?.x`. + // Typed composite literals (`[]any{...}`, `T{...}`) would + // need a backwards type-expression walk; decline those and + // let the parser report the stray `?`. + j := matchBraceBack(toks, i) + if j < 0 { + return -1 + } + if j == 0 { + return 0 + } + if startsTypedComposite(toks[j-1].kind) { + return -1 + } + return j case token.RPAREN: j := matchParenBack(toks, i) if j < 0 { @@ -265,12 +305,44 @@ func matchParenBack(toks []tokenInfo, rparen int) int { return -1 } +// matchBraceBack returns the index of the `{` that opens the `}` +// at rbrace. Negative on imbalance. +func matchBraceBack(toks []tokenInfo, rbrace int) int { + depth := 1 + for j := rbrace - 1; j >= 0; j-- { + switch toks[j].kind { + case token.RBRACE: + depth++ + case token.LBRACE: + depth-- + if depth == 0 { + return j + } + } + } + return -1 +} + +// startsTypedComposite reports whether a `{` directly preceded by t +// belongs to a typed composite literal (`[]any{...}`, `T{...}`, +// `map[string]any{...}`) rather than a bare object literal. +func startsTypedComposite(t token.Token) bool { + switch t { + case token.IDENT, token.RBRACK, token.RBRACE, token.RPAREN, + token.MAP, token.STRUCT, token.INTERFACE, token.CHAN, token.FUNC: + return true + } + return false +} + // extendsPrimary reports whether t can directly precede a `(` that // is part of a call expression. A `(` after one of these tokens is -// always a call; after anything else it begins a paren group. +// always a call; after anything else it begins a paren group. `map` +// and `if` are included because expr accepts them as call targets +// (`map(xs, it)?[0]`, `if(c, a, b)?.x`). func extendsPrimary(t token.Token) bool { switch t { - case token.IDENT, token.RBRACK, token.RPAREN: + case token.IDENT, token.RBRACK, token.RPAREN, token.MAP, token.IF: return true } return false diff --git a/internal/optaccess/optaccess_test.go b/internal/optaccess/optaccess_test.go index 50f6278..83b8986 100644 --- a/internal/optaccess/optaccess_test.go +++ b/internal/optaccess/optaccess_test.go @@ -46,10 +46,36 @@ func TestRewrite(t *testing.T) { // inside higher-order predicate {`map(users, it?.name)`, `map(users, __try_select__(it, "name"))`}, + // LHS is an array literal, including one that opens the input + {`[1, 2]?[0]`, `__try_index__([1, 2], 0)`}, + {`[1, 2]?.x`, `__try_select__([1, 2], "x")`}, + {`x + [1, 2]?[0]`, `x + __try_index__([1, 2], 0)`}, + // LHS is a bare object literal, including nested braces + {`{"a": 1}?.a`, `__try_select__({"a": 1}, "a")`}, + {`{"a": {"b": 1}}?.a`, `__try_select__({"a": {"b": 1}}, "a")`}, + {`f({"a": 1}?.a)`, `f(__try_select__({"a": 1}, "a"))`}, + + // `map` / `if` keyword call targets walk like identifiers + {`map(xs, it)?[0]`, `__try_index__(map(xs, it), 0)`}, + {`if(c, a, b)?.x`, `__try_select__(if(c, a, b), "x")`}, + // `map` / `if` as selector-chain links and as the field name + {`a.map?.x`, `__try_select__(a.map, "x")`}, + {`a?.map`, `__try_select__(a, "map")`}, + {`a?.if`, `__try_select__(a, "if")`}, + + // typed composite literals are declined (no backwards type + // walk); the parser reports the stray `?` + {`[]any{1}?.x`, `[]any{1}?.x`}, + {`map[string]any{"a": 1}?.a`, `map[string]any{"a": 1}?.a`}, + // `?` not followed by `.` or `[` is left alone {`a ? b : c`, `a ? b : c`}, // `?` at start of source: nothing to rewrite {`?.x`, `?.x`}, + // `?` with no primary to its left: declined rather than + // splicing an empty LHS into the sentinel call + {`1 + ?.x`, `1 + ?.x`}, + {`f(, ?[0])`, `f(, ?[0])`}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { diff --git a/llms.txt b/llms.txt index c629677..c0b9511 100644 --- a/llms.txt +++ b/llms.txt @@ -280,9 +280,9 @@ See `docs/guides/examples.md` for worked multi-line expressions. - **Equality (`==`, `!=`)** is loose: mixed int/float kinds compare as `float64`; typed nil values compare equal to literal `nil`; mismatched types are `false` without error; uncomparable types error. -- **Arithmetic:** int+int → int64 (wraps on overflow); any float mix → - float64; `+` on strings is concatenation. Division/modulo by zero - errors, not panics. +- **Arithmetic:** int+int → int64 (overflow-checked: out-of-range + results error rather than wrap); any float mix → float64; `+` on + strings is concatenation. Division/modulo by zero errors, not panics. - **Truthiness:** nil, false, zero numbers, empty string, empty slice/array/map, and typed-nil nilable kinds are falsey. Everything else is truthy. String content is not inspected: `bool("false")` is diff --git a/optional_access_test.go b/optional_access_test.go index e030e01..6e808ae 100644 --- a/optional_access_test.go +++ b/optional_access_test.go @@ -165,3 +165,86 @@ func TestOptionalAccess_StringLiteralUntouched(t *testing.T) { require.NoError(t, err) require.Equal(t, "obj?.field", got) } + +// Array and object literals are primaries, so `?.` / `?[` work on +// them directly — including when the literal opens the expression. +func TestOptionalAccess_LiteralReceivers(t *testing.T) { + cases := []struct { + expr string + want any + }{ + {`[1, 2, 3]?[0]`, int64(1)}, + {`[1, 2, 3]?[9]`, nil}, + {`{"a": 1}?.a`, int64(1)}, + {`{"a": 1}?.missing`, nil}, + {`{"a": [1, 2]}?.a?[1]`, int64(2)}, + {`1 + [10, 20]?[1]`, int64(21)}, + } + for _, tc := range cases { + t.Run(tc.expr, func(t *testing.T) { + got, err := evalExpr(t.Context(), tc.expr, nil) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// The results of `map(...)` and `if(...)` calls accept optional +// access just like ordinary function calls, even though Go reserves +// those keywords. +func TestOptionalAccess_SpecialFormReceivers(t *testing.T) { + env := map[string]any{"xs": []any{int64(1), int64(2)}} + cases := []struct { + expr string + want any + }{ + {`map(xs, it * 2)?[0]`, int64(2)}, + {`map(xs, it * 2)?[9]`, nil}, + {`if(true, {"a": 1}, nil)?.a`, int64(1)}, + {`filter(xs, it > 1)?[0]`, int64(2)}, + } + for _, tc := range cases { + t.Run(tc.expr, func(t *testing.T) { + got, err := evalExpr(t.Context(), tc.expr, env, WithBuiltins()) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// `?.map` and `?.if` mirror the non-optional `.map` / `.if` +// selectors, which expr accepts despite Go reserving the keywords. +func TestOptionalSelect_KeywordField(t *testing.T) { + env := map[string]any{ + "a": map[string]any{"map": int64(7), "if": int64(8)}, + "b": map[string]any{}, + } + cases := []struct { + expr string + want any + }{ + {`a?.map`, int64(7)}, + {`a?.if`, int64(8)}, + {`b?.map`, nil}, + } + for _, tc := range cases { + t.Run(tc.expr, func(t *testing.T) { + got, err := evalExpr(t.Context(), tc.expr, env) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// Calling the result of an optional access is not supported; the +// mistake is reported at Compile time with a message that names the +// operator rather than leaking the internal sentinel. +func TestOptionalAccess_CallResultRejectedAtCompile(t *testing.T) { + for _, src := range []string{`a?.b()`, `a?[0]()`} { + t.Run(src, func(t *testing.T) { + _, err := Compile(src) + require.ErrorIs(t, err, ErrCompile) + require.Contains(t, err.Error(), "optional access") + }) + } +} diff --git a/program.go b/program.go index d700f10..0b8e39b 100644 --- a/program.go +++ b/program.go @@ -111,6 +111,22 @@ func (p *Program) prewalk(node ast.Expr) ast.Expr { return n case *ast.UnaryExpr: n.X = p.prewalk(n.X) + // `-9223372036854775808` (MinInt64) is special: the bare + // literal overflows int64 on its own, so it never reaches + // litCache and the generic fold below can't see it. Parse the + // negation as a single signed literal instead, matching how + // users read it. + if n.Op == token.SUB { + if lit, ok := n.X.(*ast.BasicLit); ok && lit.Kind == token.INT { + if _, cached := p.litCache[lit]; !cached { + if i, err := strconv.ParseInt("-"+lit.Value, 0, 64); err == nil { + if wrapped, ok := p.wrapConst(i); ok { + return wrapped + } + } + } + } + } if xv, ok := p.constValue(n.X); ok { if v, err := applyUnary(n.Op, xv); err == nil { if wrapped, ok := p.wrapConst(v); ok { diff --git a/validate.go b/validate.go index 978a6f6..f2940f8 100644 --- a/validate.go +++ b/validate.go @@ -64,6 +64,9 @@ func validate(fset *token.FileSet, node ast.Expr) error { if n.Ellipsis != token.NoPos { return validateErr(fset, n.Ellipsis, "spread call arguments (...) are not supported") } + if err := validateCallTarget(fset, n.Fun); err != nil { + return err + } if err := validate(fset, n.Fun); err != nil { return err } @@ -106,6 +109,32 @@ func validate(fset *token.FileSet, node ast.Expr) error { return validateErr(fset, node.Pos(), "unsupported syntax %T", node) } +// validateCallTarget rejects call targets the evaluator cannot +// resolve. resolveCallable supports bare identifiers and selector +// expressions; anything else (an index expression, a parenthesized +// callee, the result of another call) would fail at Run time with +// "unsupported call target", so surface it at Compile time instead. +// Calls on optional-access results get a tailored message because +// the sentinel call the rewrite produced would otherwise leak into +// a confusing generic error. +func validateCallTarget(fset *token.FileSet, fun ast.Expr) error { + switch f := fun.(type) { + case *ast.Ident, *ast.SelectorExpr: + return nil + case *ast.CallExpr: + if id, ok := f.Fun.(*ast.Ident); ok { + switch id.Name { + case trySelectFormName: + return validateErr(fset, fun.Pos(), "cannot call the result of optional access (a?.b())") + case tryIndexFormName: + return validateErr(fset, fun.Pos(), "cannot call the result of optional access (a?[i]())") + } + } + return validateErr(fset, fun.Pos(), "call target must be a function name or selector, not a call result") + } + return validateErr(fset, fun.Pos(), "call target must be a function name or selector") +} + // validateCompositeType mirrors the runtime check in evalCompositeLit // so users see the same message whether the literal is rejected at // Compile or at Run time. The two accepted shapes are []any{...} and diff --git a/validate_test.go b/validate_test.go index 6d76858..49f12e6 100644 --- a/validate_test.go +++ b/validate_test.go @@ -37,6 +37,10 @@ func TestValidate_RejectsAtCompile(t *testing.T) { // Calls {"spread", "f(xs...)", "spread"}, + {"call_target_index", "fns[0]()", "call target"}, + {"call_target_paren", "(f)(1)", "call target"}, + {"call_target_call", "f()(1)", "call target"}, + {"call_target_optional", "a?.b()", "optional access"}, // Compound expression nodes {"slice_expr", "a[1:2]", "slice expression"},