From 4cde674552e69c26a0d1a1d209338d1b9a14b1ba Mon Sep 17 00:00:00 2001 From: Curtis Myzie Date: Thu, 29 Oct 2026 23:22:25 -0400 Subject: [PATCH 1/2] feat: report call sites with Program.Calls, and literals with Program.Literal Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + calls.go | 155 +++++++++++++++++++++++++++++++++++++++++ calls_test.go | 111 +++++++++++++++++++++++++++++ docs/reference/spec.md | 30 ++++++++ llms.txt | 7 ++ 5 files changed, 304 insertions(+) create mode 100644 calls.go create mode 100644 calls_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 45d6556..f355bcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ parameter interpolation. | `builtin_groups.go` | Opt-in `MathFuncs()` / `StringFuncs()` / `CollectionFuncs()` sets | | `higher_order.go` | `map`, `filter`, `any`, `all`, `find`, `count`, `try`, `if` forms | | `identifiers.go` | `Program.Identifiers()` — env-referenced name collection | +| `calls.go` | `Program.Calls()` / `Program.Literal()` — call sites and literals | | `types.go` + `check.go` | `Type` and `Program.Check` / `Template.Check` — static type checking | | `template.go` | `${...}` interpolation via `NewTemplate` / `Render` | | `truthy.go` | `IsTruthy` rules used by `!`, `&&`, `\|\|`, `bool(v)` | diff --git a/calls.go b/calls.go new file mode 100644 index 0000000..20a2109 --- /dev/null +++ b/calls.go @@ -0,0 +1,155 @@ +package expr + +import "go/ast" + +// Call is one call of a registered function, as Program.Calls reports it. +type Call struct { + // Name is the function's name as written. + Name string + // Args holds each argument as its own Program, compiled with the + // options of the program it came from. A pipe is already desugared: + // `a | f(x)` reports f with Args a and x. + Args []*Program + // Bound is true when the call sits in the body of an iterating form + // (map, filter, flatMap, any, all, find, count, sortBy), where the + // form binds `it`, `index` or a named element, so the call runs once + // per element. + Bound bool +} + +// Calls returns each call of the named functions, in source order, +// including calls nested in another call's arguments. Hosts use it to +// find calls they must handle before a Run, such as functions whose +// answers come from outside the program. +// +// Only calls by bare name count: a name shadowed by an iterating form's +// named binding is not a call of the function. The analysis is static, +// like Identifiers: an env entry that shadows a registered function at +// Run time is still reported. +func (p *Program) Calls(names ...string) []Call { + want := make(map[string]bool, len(names)) + for _, n := range names { + want[n] = true + } + var out []Call + p.walkCalls(p.root, false, want, &out) + return out +} + +func (p *Program) walkCalls(node ast.Expr, bound bool, want map[string]bool, out *[]Call) { + switch n := node.(type) { + case *ast.ParenExpr: + p.walkCalls(n.X, bound, want, out) + case *ast.UnaryExpr: + p.walkCalls(n.X, bound, want, out) + case *ast.BinaryExpr: + p.walkCalls(n.X, bound, want, out) + p.walkCalls(n.Y, bound, want, out) + case *ast.SelectorExpr: + p.walkCalls(n.X, bound, want, out) + case *ast.IndexExpr: + p.walkCalls(n.X, bound, want, out) + p.walkCalls(n.Index, bound, want, out) + case *ast.CompositeLit: + for _, e := range n.Elts { + if kv, ok := e.(*ast.KeyValueExpr); ok { + p.walkCalls(kv.Key, bound, want, out) + p.walkCalls(kv.Value, bound, want, out) + continue + } + p.walkCalls(e, bound, want, out) + } + case *ast.CallExpr: + ident, _ := n.Fun.(*ast.Ident) + if ident != nil && itBindingForms[ident.Name] { + if _, shadowed := p.funcs[displayIdent(ident.Name)]; !shadowed { + if coll, _, body, err := splitFormArgs(ident.Name, n); err == nil { + p.walkCalls(coll, bound, want, out) + p.walkCalls(body, true, want, out) + return + } + } + } + if ident != nil && want[displayIdent(ident.Name)] { + c := Call{Name: displayIdent(ident.Name), Bound: bound} + for _, a := range n.Args { + c.Args = append(c.Args, p.sub(a)) + } + *out = append(*out, c) + } else { + p.walkCalls(n.Fun, bound, want, out) + } + for _, a := range n.Args { + p.walkCalls(a, bound, want, out) + } + } +} + +// sub returns the subtree node as a Program of its own. It shares the +// parent's caches, which are read-only after compile, and its Source is +// the subtree printed back in display form. +func (p *Program) sub(node ast.Expr) *Program { + return &Program{ + source: exprDisplayString(node), + root: node, + funcs: p.funcs, + prepared: p.prepared, + fieldTags: p.fieldTags, + callCache: p.callCache, + litCache: p.litCache, + identifiers: collectIdentifiers(node, p.funcs), + evalBudget: p.evalBudget, + } +} + +// Literal returns the program's value when the whole expression is a +// literal: a string, number, bool or nil, or a list or object built only +// from literals (`["a", "b"]`, `{"k": 1}`). Constant arithmetic folded at +// compile time (`-1`, `2 * 3`) counts as a literal. Numbers come back as +// int64 or float64, lists as []any and objects as map[string]any. +func (p *Program) Literal() (any, bool) { + return p.literal(p.root) +} + +func (p *Program) literal(node ast.Expr) (any, bool) { + if v, ok := p.constValue(node); ok { + return v, true + } + switch n := node.(type) { + case *ast.ParenExpr: + return p.literal(n.X) + case *ast.CompositeLit: + switch n.Type.(type) { + case *ast.ArrayType: + out := make([]any, 0, len(n.Elts)) + for _, e := range n.Elts { + v, ok := p.literal(e) + if !ok { + return nil, false + } + out = append(out, v) + } + return out, true + case *ast.MapType: + out := make(map[string]any, len(n.Elts)) + for _, e := range n.Elts { + kv, ok := e.(*ast.KeyValueExpr) + if !ok { + return nil, false + } + k, ok := p.literal(kv.Key) + key, isString := k.(string) + if !ok || !isString { + return nil, false + } + v, ok := p.literal(kv.Value) + if !ok { + return nil, false + } + out[key] = v + } + return out, true + } + } + return nil, false +} diff --git a/calls_test.go b/calls_test.go new file mode 100644 index 0000000..5f5aef9 --- /dev/null +++ b/calls_test.go @@ -0,0 +1,111 @@ +package expr + +import ( + "context" + "testing" + + "github.com/deepnoodle-ai/expr/internal/require" +) + +func askFuncs() Option { + return WithFunctions(map[string]any{ + "ask": func(q string, x any) float64 { return 0.5 }, + "pick": func(q string, x any, opts []any) any { return opts[0] }, + }) +} + +func compileCalls(t *testing.T, src string, names ...string) []Call { + t.Helper() + p, err := Compile(src, askFuncs(), WithBuiltins()) + require.NoError(t, err) + return p.Calls(names...) +} + +func TestCalls_SourceOrderAndArgs(t *testing.T) { + calls := compileCalls(t, `ask("a?", x.body) > 0.8 || pick("b?", y, ["p", "q"]) == "p"`, "ask", "pick") + require.Len(t, calls, 2) + require.Equal(t, "ask", calls[0].Name) + require.Equal(t, "pick", calls[1].Name) + require.Len(t, calls[0].Args, 2) + require.Len(t, calls[1].Args, 3) + require.Equal(t, "x.body", calls[0].Args[1].Source()) + require.Equal(t, []string{"x"}, calls[0].Args[1].Identifiers()) + require.False(t, calls[0].Bound) +} + +func TestCalls_OnlyNamedFunctions(t *testing.T) { + require.Len(t, compileCalls(t, `ask("a?", len(x)) > 0`, "pick"), 0) + require.Len(t, compileCalls(t, `ask("a?", len(x)) > 0`, "ask"), 1) +} + +func TestCalls_ArgumentsRunAlone(t *testing.T) { + calls := compileCalls(t, `ask("a?", {"n": len(x.items), "t": x?.title}) > 0.5`, "ask") + require.Len(t, calls, 1) + v, err := calls[0].Args[1].Run(context.Background(), map[string]any{ + "x": map[string]any{"items": []any{1, 2}, "title": "hi"}, + }) + require.NoError(t, err) + require.Equal(t, map[string]any{"n": 2, "t": "hi"}, v) +} + +func TestCalls_NestedAndShortCircuitBranches(t *testing.T) { + // Every call is reported, even in a branch Run would skip. + calls := compileCalls(t, `false && ask("a?", x) > 0 || if(c, ask("b?", ask("c?", y)) > 0, false)`, "ask") + require.Len(t, calls, 3) + q := func(c Call) any { v, _ := c.Args[0].Literal(); return v } + require.Equal(t, "a?", q(calls[0])) + require.Equal(t, "b?", q(calls[1])) + require.Equal(t, "c?", q(calls[2])) +} + +func TestCalls_Bound(t *testing.T) { + calls := compileCalls(t, `any(filter(xs, ask("a?", it) > 0.5), ask("b?", it) > 0) && ask("c?", xs) > 0`, "ask") + require.Len(t, calls, 3) + require.True(t, calls[0].Bound) // filter body + require.True(t, calls[1].Bound) // any body + require.False(t, calls[2].Bound) // top level + named := compileCalls(t, `map(xs, x, ask("a?", x))`, "ask") + require.Len(t, named, 1) + require.True(t, named[0].Bound) + coll := compileCalls(t, `map([ask("a?", xs)], it)`, "ask") + require.Len(t, coll, 1) + require.False(t, coll[0].Bound) // the collection is evaluated once +} + +func TestCalls_Pipe(t *testing.T) { + calls := compileCalls(t, `x | ask("a?") > 0`, "ask") + require.Len(t, calls, 1) + require.Equal(t, "x", calls[0].Args[0].Source()) + _, isLit := calls[0].Args[0].Literal() + require.False(t, isLit) +} + +func TestLiteral(t *testing.T) { + for _, tc := range []struct { + src string + want any + }{ + {`"a"`, "a"}, + {`3`, int64(3)}, + {`-1.5`, -1.5}, + {`2 * 3`, int64(6)}, + {`true`, true}, + {`nil`, nil}, + {`["a", "b"]`, []any{"a", "b"}}, + {`{"k": [1, {"n": false}]}`, map[string]any{"k": []any{int64(1), map[string]any{"n": false}}}}, + {`[]`, []any{}}, + {`("a")`, "a"}, + } { + p, err := Compile(tc.src) + require.NoError(t, err) + v, ok := p.Literal() + require.True(t, ok, tc.src) + require.Equal(t, tc.want, v, tc.src) + } + for _, src := range []string{`x`, `"a" + x`, `["a", x]`, `{"k": x}`, `len("a")`, `x == 1`} { + p, err := Compile(src, WithBuiltins()) + require.NoError(t, err) + _, ok := p.Literal() + require.False(t, ok, src) + } +} diff --git a/docs/reference/spec.md b/docs/reference/spec.md index e29338e..7181df2 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -1102,6 +1102,36 @@ excluded name may still be read from the env when a host deliberately shadows it. Every name that can only resolve through the env is always included. +## `Program.Calls()` and `Program.Literal()` + +`Program.Calls(names...)` returns each call of the named registered +functions, in source order, including calls nested in other calls and +calls in branches that `&&`, `||`, `if` or `try` may skip at run time. +Each `Call` has the function's `Name`, its `Args` as programs of their +own, and `Bound`: true when the call sits in the body of an iterating +form, where it runs once per element. A pipe is desugared first, so +`x | f("q")` reports `f` with the arguments `x` and `"q"`. Hosts use it +to find calls they must prepare before a `Run`, for example functions +whose answers come from outside the program. + +An argument program runs and type-checks like any program. Its +`Source()` is the argument printed back in display form. + +`Program.Literal()` returns the value of an expression that is only a +literal: a string, number, bool or nil, or a list or object of literals. +Constant arithmetic that compile folds (`-1`, `2 * 3`) counts. Numbers +come back as `int64` or `float64`, lists as `[]any`, objects as +`map[string]any`. + +```go +p, _ := expr.Compile(`ask("Is it urgent?", ticket.body) > 0.8`, opts...) +for _, c := range p.Calls("ask") { + q, isLit := c.Args[0].Literal() // "Is it urgent?", true + subject := c.Args[1] // a *Program for ticket.body + _, _, _ = q, isLit, subject +} +``` + ## Error model All runtime failures wrap `ErrEvaluate`; all parse failures wrap diff --git a/llms.txt b/llms.txt index e5e59a3..cab241b 100644 --- a/llms.txt +++ b/llms.txt @@ -447,6 +447,13 @@ a three-arg form body is included (the form does not bind it). Useful for validating an expression against a known env shape at load time or tracking dependency sets for cache invalidation. +`Program.Calls(names...)` returns each call of the named registered +functions in source order, nested calls and calls in skipped branches +included. Each `Call` has `Name`, `Args []*Program` (each argument as its +own program) and `Bound` (inside an iterating form's body). Pipes are +desugared first. `Program.Literal()` returns `(value, true)` when the +whole expression is a literal or a list or object of literals. + ```go if errors.Is(err, expr.ErrCompile) { /* ... */ } if errors.Is(err, expr.ErrEvaluate) { /* ... */ } From 62456f966aa9a42e66c5f25b421933f93325c775 Mon Sep 17 00:00:00 2001 From: Curtis Myzie Date: Thu, 29 Oct 2026 23:40:31 -0400 Subject: [PATCH 2/2] fix: skip calls of a named binding in Calls, and document bound arguments Co-Authored-By: Claude Opus 5.5 --- calls.go | 23 ++++++++++++++++------- calls_test.go | 20 ++++++++++++++++++++ docs/reference/spec.md | 13 +++++++++---- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/calls.go b/calls.go index 20a2109..08b2f08 100644 --- a/calls.go +++ b/calls.go @@ -8,7 +8,9 @@ type Call struct { Name string // Args holds each argument as its own Program, compiled with the // options of the program it came from. A pipe is already desugared: - // `a | f(x)` reports f with Args a and x. + // `a | f(x)` reports f with Args a and x. An argument of a Bound call + // that reads `it`, `index` or a named element has those only per + // element, inside the form: run on its own, it fails to resolve them. Args []*Program // Bound is true when the call sits in the body of an iterating form // (map, filter, flatMap, any, all, find, count, sortBy), where the @@ -32,11 +34,15 @@ func (p *Program) Calls(names ...string) []Call { want[n] = true } var out []Call - p.walkCalls(p.root, false, want, &out) + p.walkCalls(p.root, nil, want, &out) return out } -func (p *Program) walkCalls(node ast.Expr, bound bool, want map[string]bool, out *[]Call) { +// walkCalls records the calls of the wanted functions under node. bound +// holds the element bindings of the enclosing iterating forms: non-nil +// means the node runs per element, and a call of a bound name calls the +// element, not the function. +func (p *Program) walkCalls(node ast.Expr, bound *boundIdents, want map[string]bool, out *[]Call) { switch n := node.(type) { case *ast.ParenExpr: p.walkCalls(n.X, bound, want, out) @@ -63,15 +69,18 @@ func (p *Program) walkCalls(node ast.Expr, bound bool, want map[string]bool, out ident, _ := n.Fun.(*ast.Ident) if ident != nil && itBindingForms[ident.Name] { if _, shadowed := p.funcs[displayIdent(ident.Name)]; !shadowed { - if coll, _, body, err := splitFormArgs(ident.Name, n); err == nil { + if coll, bind, body, err := splitFormArgs(ident.Name, n); err == nil { p.walkCalls(coll, bound, want, out) - p.walkCalls(body, true, want, out) + if bind == "" { + bind = "it" + } + p.walkCalls(body, &boundIdents{element: bind, parent: bound}, want, out) return } } } - if ident != nil && want[displayIdent(ident.Name)] { - c := Call{Name: displayIdent(ident.Name), Bound: bound} + if ident != nil && want[displayIdent(ident.Name)] && !bound.has(displayIdent(ident.Name)) { + c := Call{Name: displayIdent(ident.Name), Bound: bound != nil} for _, a := range n.Args { c.Args = append(c.Args, p.sub(a)) } diff --git a/calls_test.go b/calls_test.go index 5f5aef9..6132ced 100644 --- a/calls_test.go +++ b/calls_test.go @@ -72,6 +72,26 @@ func TestCalls_Bound(t *testing.T) { require.False(t, coll[0].Bound) // the collection is evaluated once } +func TestCalls_NamedBindingShadows(t *testing.T) { + // Inside the body, ask names the element, so ask(...) is not a call + // of the function; its argument still is walked. + require.Len(t, compileCalls(t, `map(xs, ask, ask("a?", ask))`, "ask"), 0) + calls := compileCalls(t, `map(xs, ask, ask(pick("b?", ask, ["p"])))`, "ask", "pick") + require.Len(t, calls, 1) + require.Equal(t, "pick", calls[0].Name) + // Outside the body the name is the function again. + require.Len(t, compileCalls(t, `map(xs, ask, ask) == [] || ask("c?", xs) > 0`, "ask"), 1) +} + +func TestCalls_BoundArgumentRunsOnlyPerElement(t *testing.T) { + calls := compileCalls(t, `any(xs, ask("a?", it.body) > 0.5)`, "ask") + require.Len(t, calls, 1) + require.True(t, calls[0].Bound) + _, err := calls[0].Args[1].Run(context.Background(), map[string]any{"xs": []any{}}) + require.Error(t, err) + require.ErrorIs(t, err, ErrEvaluate) +} + func TestCalls_Pipe(t *testing.T) { calls := compileCalls(t, `x | ask("a?") > 0`, "ask") require.Len(t, calls, 1) diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 7181df2..ae8b886 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -1115,7 +1115,11 @@ to find calls they must prepare before a `Run`, for example functions whose answers come from outside the program. An argument program runs and type-checks like any program. Its -`Source()` is the argument printed back in display form. +`Source()` is the argument printed back in display form. An argument of a +`Bound` call that reads `it`, `index` or a named element has them only per +element, inside the form: run on its own, it fails to resolve them. Inside +a three-arg form's body, a call of the binding's name calls the element, +so it is not reported. `Program.Literal()` returns the value of an expression that is only a literal: a string, number, bool or nil, or a list or object of literals. @@ -1124,12 +1128,13 @@ come back as `int64` or `float64`, lists as `[]any`, objects as `map[string]any`. ```go -p, _ := expr.Compile(`ask("Is it urgent?", ticket.body) > 0.8`, opts...) +p, _ := expr.Compile(`false && ask("Is it urgent?", ticket.body) > 0.8`, opts...) for _, c := range p.Calls("ask") { q, isLit := c.Args[0].Literal() // "Is it urgent?", true - subject := c.Args[1] // a *Program for ticket.body - _, _, _ = q, isLit, subject + v, _ := c.Args[1].Run(ctx, env) // ticket.body's value + fmt.Println(c.Args[1].Source(), c.Bound, q, isLit, v) } +// ticket.body false Is it urgent? true ``` ## Error model