Skip to content

Commit 1a36a16

Browse files
Conformance fixes and performance optimizations (#13)
* Fix signature validation for variadic, optional-skip, context, and u-type Addresses four conformance gaps in function signature validation against the JSONata spec: - Variadic overflow: cap consumption to leave room for mandatory params that follow, preventing spurious T0410 "too few arguments" errors when a trailing fixed param is present after a variadic. - Optional-skip: when an optional param's type doesn't match, skip the spec and retry the same arg against the next spec instead of raising T0410. - '-' (context) modifier: inject the current focus value when the argument is absent. Adds Context field to ParamSpec and threads focus through processCallArgs / validateCallArgs. - 'u' type specifier (union of primitives: bool, number, string, null). Recognised by both the parser and evaluator. Adds test cases 035-040 covering each scenario end-to-end. * Return T0410 from $split on non-string input $split previously returned nil (undefined) for non-string arguments, which matched the jsonata-js reference implementation but diverged from the JSONata spec, which prescribes a T0410 type error. Align with the spec by returning T0410 for non-string args, and update case016/case017 to expect the error code instead of an undefined result. * Sort raw map keys for deterministic iteration When a Go native map[string]any enters the evaluator (via DecodeRawMap or MapKeys/MapRange in paths that walk raw maps), iteration order was Go-randomised, which leaked into ordering-sensitive operations like $keys, $lookup fallbacks, and aggregate results. Sort keys alphabetically using slices.Sorted(maps.Keys(m)) before iteration so identical inputs produce identical outputs across runs. * Pre-parse function signatures at registration Signature strings were re-parsed on every SignedBuiltin / Lambda call via parser.ParseSig, which is pure overhead since the string never changes after the function is defined. Parse once and cache: - Add ParsedSig []parser.ParamSpec to SignedBuiltin and Lambda. - Introduce a newSignedBuiltin helper in functions/register.go that parses at construction time; use it for signature-carrying registrations. - Populate Lambda.ParsedSig once in evalLambda when the signature is compiled from the AST. - Replace the per-call parser.ParseSig() lookups in evalFunction and callFunction with direct reads of the cached ParsedSig slice. Hot-path-only change; no behavioural difference. * Optimize eval hot paths: float arith, DeepEqual, HOF buffers Five targeted hot-path optimisations in the evaluator and function dispatch layer: - Arithmetic fast-path (eval_binary.go): when both operands are already float64, bypass the generic numeric-coercion path and go straight to evalArithFloat64. This is the common case for numeric expressions after AST evaluation. - DeepEqual primitive fast-path (value.go): for same-type float64 / string / bool comparisons, skip normalizeNumber and compare directly. Drops a per-call allocation that showed up in profiles on equality-heavy expressions. - Sequence collapse (value.go): use slices.Clip in CollapseSequence and CollapseToSlice instead of slices.Clone. Since the sequence is being discarded, we can transfer ownership of the backing array rather than allocating a copy. - HOF argument buffers (hof_funcs.go, object_funcs.go): replace the per-iteration hofArgs slice with reusable hofArity / hofArgsBuf / fillHofArgs helpers applied to $map, $filter, $single, $reduce, $sift, and $each. Same arity detection, zero per-iteration allocation. - Sort comparator args (array_funcs.go): pre-allocate the 2-element sortArgs slice once per $sort call instead of rebuilding it on each comparator invocation. No behavioural changes; all existing tests pass. * Add EvalMap, EvalBytesWithVars; expose via WASM; fix gjson docs Extends the public Expression API and the WASM bridge with two evaluation paths that were previously only reachable from the StreamEvaluator or the byte-level API, and cleans up the docs to match how gjson is actually used. Expression API (gnata.go): - EvalMap(ctx, data map[string]json.RawMessage): O(1) top-level key lookup via DecodeRawMap, with gjson fast paths for nested access inside each RawMessage. Useful when the caller already has the JSON decoded into a map and wants to avoid re-serialising. - EvalBytesWithVars(ctx, data json.RawMessage, vars map[string]any): evaluate raw JSON bytes with external $-variable bindings, while keeping the gjson fast-path eligible. WASM bridge (wasm/main.go, playground.html): - Export two new JS functions, gnataEvalMap and gnataEvalWithVars, mirroring the new Expression methods. gnataEvalMap takes a JS object and feeds its top-level keys as json.RawMessage values; gnataEvalWithVars takes a vars JSON blob for $-bindings. - Route gnataEval and gnataEvalHandle through EvalBytes so they also benefit from the gjson fast path. - Add JS wrappers in playground.html so the new exports are usable from the browser playground. Documentation (README.md, AGENTS.md): - Fix the StreamEvaluator description: the hot path uses gjson.GetBytes per fast-path expression, not a single gjson.GetManyBytes call for the whole event. - Update the public-API list in README to include EvalMap and EvalBytesWithVars, and expand the WASM section with a table of all six exported JS functions.
1 parent db5bf98 commit 1a36a16

25 files changed

Lines changed: 440 additions & 150 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Lexer → Parser → AST Processing → Fast-Path Analysis → Expression
4343

4444
### StreamEvaluator (stream.go)
4545

46-
Batch-evaluates multiple expressions against events. Schema-keyed `GroupPlan` caching deduplicates field extraction across expressions. Lock-free reads via `atomic.Pointer` snapshot; writes serialized by `sync.Mutex`. Single JSON scan per event via `gjson.GetManyBytes`.
46+
Batch-evaluates multiple expressions against events. Schema-keyed `GroupPlan` caching classifies expressions into fast-path vs full-eval at plan-build time. Lock-free reads via `atomic.Pointer` snapshot; writes serialized by `sync.Mutex`. Fast-path expressions use `gjson.GetBytes` for zero-copy field extraction.
4747

4848
### Evaluator Dispatch (internal/evaluator/)
4949

@@ -97,4 +97,4 @@ se := gnata.NewStreamEvaluator(nil, gnata.WithCustomFunctions(customFuncs))
9797

9898
## WASM
9999

100-
`wasm/main.go` exports `gnataEval`, `gnataCompile`, `gnataEvalHandle` for browser use. Build with `GOOS=js GOARCH=wasm go build -ldflags="-s -w" -trimpath -o gnata.wasm ./wasm/`.
100+
`wasm/main.go` exports six JS functions: `gnataEval`, `gnataCompile`, `gnataEvalHandle`, `gnataReleaseHandle`, `gnataEvalMap` (O(1) top-level key lookup via `EvalMap`), and `gnataEvalWithVars` (external `$`-variable bindings). `gnataEval` and `gnataEvalHandle` use `EvalBytes`; `gnataEvalMap` uses `EvalMap`; `gnataEvalWithVars` uses `EvalBytesWithVars`. All paths leverage gjson fast-path access where applicable. Build with `GOOS=js GOARCH=wasm go build -ldflags="-s -w" -trimpath -o gnata.wasm ./wasm/`.

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,15 @@ Hot Path (millions/day, lock-free)
148148
├── BoundedCache lookup (atomic pointer read)
149149
│ ├── HIT ──> Immutable GroupPlan
150150
│ └── MISS ──> Build plan (merge GJSON paths, atomic CAS store)
151-
├── gjson.GetManyBytes: SINGLE scan for ALL expressions
151+
├── gjson.GetBytes per fast-path expression
152152
├── Fast-path expressions: distribute extracted results (0 allocs)
153153
├── Full-path expressions: selective unmarshal + AST eval
154154
└── results[]
155155
```
156156

157157
### Key Properties
158158

159-
- **One JSON scan per event**all field paths needed by all expressions are merged into a single `gjson.GetManyBytes` call.
159+
- **Efficient JSON field extraction**fast-path expressions use `gjson.GetBytes` for zero-copy path lookups directly on raw JSON bytes.
160160
- **Schema-keyed caching** — the `GroupPlan` (merged paths, expression groupings, selective unmarshal targets) is computed once per schema key and reused immutably.
161161
- **Lock-free reads**`BoundedCache` publishes an `atomic.Pointer` snapshot on every write; reads scan the snapshot without acquiring a lock. Writes are serialised by a mutex.
162162
- **Selective unmarshal** — full-path expressions unmarshal only the subtrees they need (e.g., just the `items` array from a 10KB event), not the entire document.
@@ -421,7 +421,7 @@ All standard regex features (character classes, quantifiers, alternation, groupi
421421

422422
```
423423
gnata/
424-
├── gnata.go # Public API: Compile, Eval, EvalBytes, EvalWithVars, CustomFunc
424+
├── gnata.go # Public API: Compile, Eval, EvalBytes, EvalBytesWithVars, EvalMap, EvalWithVars, CustomFunc
425425
├── stream.go # StreamEvaluator, GroupPlan, EvalMany, EvalMap, MetricsHook
426426
├── bounded_cache.go # Lock-free FIFO ring-buffer plan cache
427427
├── deep_equal.go # JSONata-compatible deep equality
@@ -485,7 +485,18 @@ python3 -m http.server 8899
485485
caddy file-server --root . --listen :8899
486486
```
487487

488-
The WASM build exposes `gnataEval`, `gnataCompile`, and `gnataEvalHandle` functions for use from JavaScript, with a compiled-expression cache for repeated evaluations. A ready-made `playground.html` is included — build the WASM binary, copy the Go WASM support file, and serve the directory:
488+
The WASM build exposes six functions for use from JavaScript (the raw exports are underscore-prefixed; `playground.html` wraps them into clean public names):
489+
490+
| Function | Description |
491+
|---|---|
492+
| `gnataEval(expr, jsonData)` | One-shot compile + evaluate (expressions are cached). |
493+
| `gnataCompile(expr)` | Compile an expression and return a numeric handle. |
494+
| `gnataEvalHandle(handle, jsonData)` | Evaluate a compiled handle against JSON data. Uses `EvalBytes` internally for gjson fast-path access. |
495+
| `gnataReleaseHandle(handle)` | Free a compiled handle. |
496+
| `gnataEvalMap(handle, jsonObject)` | Evaluate a compiled handle using `EvalMap` — O(1) top-level key lookup with gjson fast paths for nested access. Ideal for pre-destructured data. |
497+
| `gnataEvalWithVars(handle, jsonData, varsJson)` | Evaluate with external `$`-variable bindings (e.g. `{"$threshold": 100}`). |
498+
499+
A ready-made `playground.html` is included — build the WASM binary, copy the Go WASM support file, and serve the directory:
489500

490501
```bash
491502
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .

functions/array_funcs.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,11 @@ func makeFnSort(evalFn EvalFn) evaluator.EnvAwareBuiltin {
129129
// JSONata $sort comparator: fn(a, b) returns true when a should come
130130
// before b. We call fn(b, a) and map true→-1 (a<b), false→0 (a>=b).
131131
// SortItemsErr only tests < 0, so +1 is unnecessary.
132+
sortArgs := make([]any, 2)
132133
cmpFn = func(a, b any) (int, error) {
133-
result, err := evalFn(fn, []any{b, a}, focus, env)
134+
sortArgs[0] = b
135+
sortArgs[1] = a
136+
result, err := evalFn(fn, sortArgs, focus, env)
134137
if err != nil {
135138
return 0, err
136139
}

functions/hof_funcs.go

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,42 @@ import (
99
"github.com/recolabs/gnata/internal/parser"
1010
)
1111

12-
// hofArgs trims the HOF callback argument list to the function's expected arity.
13-
// For lambdas, use the declared parameter count.
14-
// For built-in functions, default to 1 (value only) since they have their own
15-
// argument validation and may reject unexpected extra args.
16-
func hofArgs(fn, value, index any, arr []any) []any {
12+
// hofArity returns the callback argument count for the given HOF function.
13+
// For lambdas, uses the declared parameter count (capped at 3).
14+
// For built-in functions, defaults to 1 (value only).
15+
func hofArity(fn any) int {
1716
if lambda, ok := fn.(*evaluator.Lambda); ok {
18-
switch len(lambda.Params) {
19-
case 0:
20-
return []any{}
21-
case 1:
22-
return []any{value}
23-
case 2:
24-
return []any{value, index}
25-
default:
26-
return []any{value, index, arr}
17+
n := len(lambda.Params)
18+
if n > 3 {
19+
return 3
2720
}
21+
return n
22+
}
23+
return 1
24+
}
25+
26+
// hofArgsBuf allocates a reusable buffer for HOF callback arguments.
27+
func hofArgsBuf(arity int) []any {
28+
if arity == 0 {
29+
return nil
30+
}
31+
return make([]any, arity)
32+
}
33+
34+
// fillHofArgs populates a pre-allocated argument buffer for a HOF callback.
35+
func fillHofArgs(buf []any, value, index any, arr []any) {
36+
switch len(buf) {
37+
case 0:
38+
case 1:
39+
buf[0] = value
40+
case 2:
41+
buf[0] = value
42+
buf[1] = index
43+
default:
44+
buf[0] = value
45+
buf[1] = index
46+
buf[2] = arr
2847
}
29-
// For built-in functions pass (value) only to avoid arity rejections.
30-
return []any{value}
3148
}
3249

3350
// ── $map ──────────────────────────────────────────────────────────────────────
@@ -56,8 +73,9 @@ func makeFnMap(evalFn EvalFn) evaluator.EnvAwareBuiltin {
5673

5774
seq := evaluator.CreateSequence()
5875
arrAny := slices.Clone(arr)
76+
callArgs := hofArgsBuf(hofArity(fn))
5977
for i, item := range arr {
60-
callArgs := hofArgs(fn, item, float64(i), arrAny)
78+
fillHofArgs(callArgs, item, float64(i), arrAny)
6179
val, err := evalFn(fn, callArgs, focus, env)
6280
if err != nil {
6381
return nil, err
@@ -94,8 +112,9 @@ func makeFnFilter(evalFn EvalFn) evaluator.EnvAwareBuiltin {
94112

95113
seq := evaluator.CreateSequence()
96114
arrAny := slices.Clone(arr)
115+
callArgs := hofArgsBuf(hofArity(fn))
97116
for i, item := range arr {
98-
callArgs := hofArgs(fn, item, float64(i), arrAny)
117+
fillHofArgs(callArgs, item, float64(i), arrAny)
99118
val, err := evalFn(fn, callArgs, focus, env)
100119
if err != nil {
101120
return nil, err
@@ -151,8 +170,9 @@ func makeFnSingle(evalFn EvalFn) evaluator.EnvAwareBuiltin {
151170
fn := args[1]
152171
var matched []any
153172
arrAny := slices.Clone(arr)
173+
callArgs := hofArgsBuf(hofArity(fn))
154174
for i, item := range arr {
155-
callArgs := hofArgs(fn, item, float64(i), arrAny)
175+
fillHofArgs(callArgs, item, float64(i), arrAny)
156176
val, err := evalFn(fn, callArgs, focus, env)
157177
if err != nil {
158178
return nil, err
@@ -218,21 +238,23 @@ func makeFnReduce(evalFn EvalFn) evaluator.EnvAwareBuiltin {
218238
}
219239

220240
arrAny := slices.Clone(arr)
241+
var reduceArity int
242+
if lambda, ok := fn.(*evaluator.Lambda); ok {
243+
reduceArity = max(min(len(lambda.Params), 4), 1)
244+
} else {
245+
reduceArity = 2
246+
}
247+
callArgs := make([]any, reduceArity)
221248
for i := startIdx; i < len(arr); i++ {
222-
var callArgs []any
223-
if lambda, ok := fn.(*evaluator.Lambda); ok {
224-
switch len(lambda.Params) {
225-
case 0, 1:
226-
callArgs = []any{acc}
227-
case 2:
228-
callArgs = []any{acc, arr[i]}
229-
case 3:
230-
callArgs = []any{acc, arr[i], float64(i)}
231-
default:
232-
callArgs = []any{acc, arr[i], float64(i), arrAny}
233-
}
234-
} else {
235-
callArgs = []any{acc, arr[i]}
249+
callArgs[0] = acc
250+
if reduceArity > 1 {
251+
callArgs[1] = arr[i]
252+
}
253+
if reduceArity > 2 {
254+
callArgs[2] = float64(i)
255+
}
256+
if reduceArity > 3 {
257+
callArgs[3] = arrAny
236258
}
237259
val, err := evalFn(fn, callArgs, focus, env)
238260
if err != nil {

functions/object_funcs.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -158,20 +158,19 @@ func fnMerge(args []any, _ any) (any, error) {
158158
return result, nil
159159
}
160160

161-
func siftArgs(fn, value any, key string, obj any) []any {
162-
if lambda, ok := fn.(*evaluator.Lambda); ok {
163-
switch len(lambda.Params) {
164-
case 0:
165-
return []any{}
166-
case 1:
167-
return []any{value}
168-
case 2:
169-
return []any{value, key}
170-
default:
171-
return []any{value, key, obj}
172-
}
161+
func fillSiftArgs(buf []any, value any, key string, obj any) {
162+
switch len(buf) {
163+
case 0:
164+
case 1:
165+
buf[0] = value
166+
case 2:
167+
buf[0] = value
168+
buf[1] = key
169+
default:
170+
buf[0] = value
171+
buf[1] = key
172+
buf[2] = obj
173173
}
174-
return []any{value}
175174
}
176175

177176
// ── $sift ─────────────────────────────────────────────────────────────────────
@@ -199,9 +198,10 @@ func makeFnSift(evalFn EvalFn) evaluator.EnvAwareBuiltin {
199198

200199
result := evaluator.NewOrderedMap()
201200
keys := evaluator.MapKeys(objVal)
201+
callArgs := hofArgsBuf(hofArity(fn))
202202
for _, ks := range keys {
203203
val, _ := evaluator.MapGet(objVal, ks)
204-
callArgs := siftArgs(fn, val, ks, objVal)
204+
fillSiftArgs(callArgs, val, ks, objVal)
205205
res, err := evalFn(fn, callArgs, focus, env)
206206
if err != nil {
207207
return nil, err
@@ -242,9 +242,11 @@ func makeFnEach(evalFn EvalFn) evaluator.EnvAwareBuiltin {
242242

243243
keys := evaluator.MapKeys(objVal)
244244
seq := evaluator.CreateSequence()
245+
callArgs := hofArgsBuf(max(hofArity(fn), 2))
245246
for _, ks := range keys {
246247
val, _ := evaluator.MapGet(objVal, ks)
247-
res, err := evalFn(fn, []any{val, ks}, focus, env)
248+
fillSiftArgs(callArgs, val, ks, objVal)
249+
res, err := evalFn(fn, callArgs, focus, env)
248250
if err != nil {
249251
return nil, err
250252
}

functions/register.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Package functions implements the JSONata 2.x standard library.
22
package functions
33

4-
import "github.com/recolabs/gnata/internal/evaluator"
4+
import (
5+
"github.com/recolabs/gnata/internal/evaluator"
6+
"github.com/recolabs/gnata/internal/parser"
7+
)
58

69
// EvalFn is a callback used by higher-order functions to invoke a lambda or
710
// builtin function value without creating an import cycle. The env parameter
@@ -77,14 +80,19 @@ var builtinFuncs = []struct {
7780
{"toMillis", fnToMillis},
7881
}
7982

83+
func newSignedBuiltin(fn func([]any, any) (any, error), sig string) *evaluator.SignedBuiltin {
84+
parsed, _ := parser.ParseSig(sig)
85+
return &evaluator.SignedBuiltin{Fn: fn, Sig: sig, ParsedSig: parsed}
86+
}
87+
8088
// RegisterAll binds every JSONata built-in function into env.
8189
// evalFn must call evaluator.ApplyFunction (supplied by gnata.go).
8290
func RegisterAll(env *evaluator.Environment, evalFn EvalFn) {
8391
for _, b := range builtinFuncs {
8492
env.Bind(b.name, evaluator.BuiltinFunction(b.fn))
8593
}
86-
env.Bind("uppercase", &evaluator.SignedBuiltin{Fn: fnUppercase, Sig: "s-:s"})
87-
env.Bind("lowercase", &evaluator.SignedBuiltin{Fn: fnLowercase, Sig: "s-:s"})
94+
env.Bind("uppercase", newSignedBuiltin(fnUppercase, "s-:s"))
95+
env.Bind("lowercase", newSignedBuiltin(fnLowercase, "s-:s"))
8896
env.Bind("match", makeFnMatch(evalFn))
8997
env.Bind("replace", makeFnReplace(evalFn))
9098
env.Bind("eval", makeFnEval())

functions/string_funcs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ func fnSplit(args []any, _ any) (any, error) {
424424
}
425425
s, ok := args[0].(string)
426426
if !ok {
427-
return nil, nil
427+
return nil, &evaluator.JSONataError{Code: "T0410", Message: fmt.Sprintf("$split: argument 1 must be a string, got %T", args[0])}
428428
}
429429
if len(args) < 2 {
430430
return nil, &evaluator.JSONataError{Code: "D3006", Message: "$split: requires at least 2 arguments"}

gnata.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,62 @@ func (e *Expression) EvalBytes(ctx context.Context, data json.RawMessage) (resul
223223
return e.Eval(ctx, v)
224224
}
225225

226+
// EvalMap evaluates the expression against a map of field names to raw JSON values.
227+
// This enables O(1) top-level key lookup with gjson fast paths for nested access,
228+
// making it ideal for pre-destructured data (e.g. database columns, form fields).
229+
func (e *Expression) EvalMap(ctx context.Context, data map[string]json.RawMessage) (result any, err error) {
230+
defer recoverEvalPanic(&err)
231+
if e.fastPath && len(e.paths) == 1 {
232+
if res := resolveGjsonPath(nil, data, e.paths[0]); res.Exists() {
233+
return gjsonValueToAny(&res), nil
234+
}
235+
}
236+
if e.cmpFast != nil {
237+
if res, handled, evalErr := evalComparison(e.cmpFast, nil, data); handled || evalErr != nil {
238+
return res, evalErr
239+
}
240+
}
241+
if e.funcFast != nil {
242+
if res, handled, evalErr := evalFunc(e.funcFast, nil, data); handled || evalErr != nil {
243+
return res, evalErr
244+
}
245+
}
246+
v, err := evaluator.DecodeRawMap(data)
247+
if err != nil {
248+
return nil, err
249+
}
250+
return e.Eval(ctx, v)
251+
}
252+
253+
// EvalBytesWithVars evaluates the expression against raw JSON bytes with extra
254+
// variable bindings. Combines the gjson fast-path cascade from EvalBytes with
255+
// the variable support from EvalWithVars. Fast-path expressions never reference
256+
// $variables (excluded at compile time), so the fast-path result is independent
257+
// of the variable map; only the full-eval fallback uses vars.
258+
func (e *Expression) EvalBytesWithVars(ctx context.Context, data json.RawMessage, vars map[string]any) (result any, err error) {
259+
defer recoverEvalPanic(&err)
260+
if e.fastPath && len(e.paths) == 1 {
261+
if res := gjson.GetBytes(data, e.paths[0]); res.Exists() {
262+
return gjsonValueToAny(&res), nil
263+
}
264+
}
265+
if e.cmpFast != nil {
266+
if res, handled, evalErr := evalComparison(e.cmpFast, data, nil); handled || evalErr != nil {
267+
return res, evalErr
268+
}
269+
}
270+
if e.funcFast != nil {
271+
if res, handled, evalErr := evalFunc(e.funcFast, data, nil); handled || evalErr != nil {
272+
return res, evalErr
273+
}
274+
}
275+
v, err := evaluator.DecodeJSON(data)
276+
if err != nil {
277+
return nil, err
278+
}
279+
return e.evalCore(ctx, v, builtinEnv, vars)
280+
}
281+
226282
// resolveGjsonPath resolves a gjson path from either raw bytes or a pre-decoded map.
227283
// When data is available (EvalMany), it delegates to gjson.GetBytes on the full blob.
228284
// When mapData is available (EvalMap), it does an O(1) map lookup for the top-level

internal/evaluator/env.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,18 @@ type EnvAwareBuiltin func(args []any, focus any, env *Environment) (any, error)
178178
// function via ApplyFunction bypass signature validation, allowing extra
179179
// arguments (key, index, array) to be passed silently.
180180
type SignedBuiltin struct {
181-
Fn BuiltinFunction
182-
Sig string
181+
Fn BuiltinFunction
182+
Sig string
183+
ParsedSig []parser.ParamSpec // pre-parsed signature; avoids re-parsing on every call
183184
}
184185

185186
// Lambda represents a user-defined function (lambda expression).
186187
type Lambda struct {
187-
Params []string // parameter names
188-
Body *parser.Node // function body AST node
189-
Closure *Environment // lexical scope at definition site
190-
Thunk bool // for tail-call optimization
191-
Sig string // type signature (Wave 5)
192-
CapturedFocus any // focus ($) captured at definition time for zero-param closures
188+
Params []string // parameter names
189+
Body *parser.Node // function body AST node
190+
Closure *Environment // lexical scope at definition site
191+
Thunk bool // for tail-call optimization
192+
Sig string // type signature (Wave 5)
193+
ParsedSig []parser.ParamSpec // pre-parsed signature; avoids re-parsing per call
194+
CapturedFocus any // focus ($) captured at definition time for zero-param closures
193195
}

0 commit comments

Comments
 (0)