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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down
164 changes: 164 additions & 0 deletions calls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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. 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
// 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, nil, want, &out)
return out
}

// 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)
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, bind, body, err := splitFormArgs(ident.Name, n); err == nil {
p.walkCalls(coll, bound, want, out)
if bind == "" {
bind = "it"
}
p.walkCalls(body, &boundIdents{element: bind, parent: bound}, want, out)
return
}
}
}
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))
}
*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
}
131 changes: 131 additions & 0 deletions calls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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_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)
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)
}
}
35 changes: 35 additions & 0 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,41 @@ 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. 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.
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(`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
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 <the body>
```

## Error model

All runtime failures wrap `ErrEvaluate`; all parse failures wrap
Expand Down
7 changes: 7 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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) { /* ... */ }
Expand Down
Loading