From 17dd22236aed8b757880e6737e2f4fe73ae5e4c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:13:42 +0000 Subject: [PATCH 1/4] Fix optional-access LHS walking for literals and keyword forms The ?. / ?[ rewriter declined or mangled several receivers that the non-optional operators accept: - An array literal that opens the expression ([1, 2]?[0]) walked off the start of the token stream and was declined, leaving a stray ? for the parser to choke on. - Bare object literals ({"a": 1}?.a) had no brace handling at all; the walker fell into the default case and spliced an empty LHS, producing a parse error that leaked the __try_select__ sentinel. - map(...) and if(...) call results (map(xs, it)?[0]) lost their keyword token during the paren walk-back, chopping the LHS to just the argument list. - a?.map / a?.if were rejected even though a.map / a.if work. Teach lhsStartIdx about brace-closed primaries and the map/if keyword tokens, return the opening bracket when it starts the input, and decline any rewrite whose LHS would be empty so malformed input gets the parser's plain illegal-character error instead of sentinel soup. https://claude.ai/code/session_01V9oqpXChcB6zJFr6yw956M --- internal/optaccess/optaccess.go | 90 +++++++++++++++++++++++++--- internal/optaccess/optaccess_test.go | 26 ++++++++ optional_access_test.go | 83 +++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 9 deletions(-) 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/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") + }) + } +} From 109a4964c3030ddab3aa13f618ea97b186cd0cd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:13:52 +0000 Subject: [PATCH 2/4] Render predicate errors in user-facing syntax formatPredicate translated only the map sentinel back to its source spelling, and did so with a string ReplaceAll. Predicate errors therefore leaked __expr_if__ and __try_select__/__try_index__ into messages, printed JSON-style literals as []any{...} composites, and corrupted string literals whose content happened to spell __expr_map__. Replace the string substitution with an AST-level display transform: a copied tree renames keyword sentinels, prints optional-access sentinel calls as recv?.field / recv?[idx], and prints []any / map[string]any composite literals in the bare JSON style users type. The original tree is never mutated, so concurrent Runs are unaffected. https://claude.ai/code/session_01V9oqpXChcB6zJFr6yw956M --- higher_order.go | 157 ++++++++++++++++++++++++++++++++++++++++--- higher_order_test.go | 32 +++++++++ 2 files changed, 180 insertions(+), 9 deletions(-) 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. From 566c1996eaa1cf8696ba31d2102bc365c99abab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:13:53 +0000 Subject: [PATCH 3/4] Accept -9223372036854775808 as the MinInt64 literal The parser yields a UnaryExpr SUB of the bare digits, which overflow int64 on their own, so the expression failed at Run time even though the value it denotes is representable. Fold the negation and literal into a single signed parse during prewalk. One past MinInt64 still errors, and overflow tests now pin the checked (erroring) behavior that the spec previously misdescribed as silent wrapping. https://claude.ai/code/session_01V9oqpXChcB6zJFr6yw956M --- boundaries2_test.go | 47 +++++++++++++++++++++++------------------- docs/reference/spec.md | 18 +++++++++++++--- program.go | 16 ++++++++++++++ 3 files changed, 57 insertions(+), 24 deletions(-) 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/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 { From 3f8d3604ca21001e9d2b1a20b5c9d23817be541d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:14:04 +0000 Subject: [PATCH 4/4] Reject unsupported call targets at Compile time Calls whose target is an index expression, another call, a paren group, or an optional access used to compile fine and then fail every Run with "unsupported call target". Surface the rejection from validate instead, continuing the move of unsupported-syntax detection to Compile time (#15). Calls on optional-access results (a?.b()) get a tailored message so the internal sentinel call the rewrite produces never leaks into a confusing generic error. Also sync the spec and llms.txt with the implemented overflow-checked integer arithmetic, which they still described as wrapping silently. https://claude.ai/code/session_01V9oqpXChcB6zJFr6yw956M --- boundaries1_test.go | 10 ++++------ llms.txt | 6 +++--- validate.go | 29 +++++++++++++++++++++++++++++ validate_test.go | 4 ++++ 4 files changed, 40 insertions(+), 9 deletions(-) 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/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/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"},