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
10 changes: 4 additions & 6 deletions boundaries1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
47 changes: 26 additions & 21 deletions boundaries2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() ---
Expand Down Expand Up @@ -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 ---
Expand All @@ -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 ---
Expand Down
18 changes: 15 additions & 3 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (`== != < <= >= >`)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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__`).
Expand Down
157 changes: 148 additions & 9 deletions higher_order.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"go/printer"
"go/token"
"reflect"
"strconv"
"strings"
)

Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions higher_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading