datalogic_rs/engine/mod.rs
1use std::cell::Cell;
2use std::collections::HashMap;
3
4use crate::config::EvaluationConfig;
5
6use crate::{CompiledNode, Logic, Result};
7
8thread_local! {
9 /// Per-thread `dispatch_node` recursion counter. Incremented at the
10 /// top of `dispatch_node` (after the literal fast path) and decremented
11 /// on the way out, so the value reflects the current sync call-stack
12 /// depth across nested `Engine::evaluate(...)` invocations.
13 ///
14 /// Why thread-local rather than a `ContextStack` field: a custom
15 /// operator can hold `Arc<Engine>` and call `engine.evaluate(...)`
16 /// recursively from inside its own `evaluate(...)` — each top-level
17 /// call constructs a fresh `ContextStack` (depth resets to 0) but
18 /// the C call stack keeps growing. A thread-local survives across
19 /// those boundaries and catches the runaway recursion before stack
20 /// overflow.
21 ///
22 /// Tokio safety: `dispatch_node` is sync, so a task can't `.await`
23 /// while holding the counter raised. Between dispatch calls the
24 /// value returns to zero, so cross-thread task migration starts
25 /// fresh on whatever thread it lands.
26 static DISPATCH_DEPTH: Cell<u32> = const { Cell::new(0) };
27}
28
29/// Restores [`DISPATCH_DEPTH`] to its prior value on drop. Used by
30/// the boundary entry points (`Engine::evaluate`, `TracedSession::evaluate`)
31/// so early returns and panics leave the counter consistent.
32///
33/// `DepthGuard(u32::MAX)` is a no-op sentinel — used when the engine has
34/// no custom operators registered, so cross-evaluate recursion is
35/// impossible and the boundary skips the TLS bookkeeping entirely. The
36/// drop check makes the guard zero-cost in that case.
37pub(crate) struct DepthGuard(u32);
38
39impl DepthGuard {
40 const NOOP: u32 = u32::MAX;
41}
42
43impl Drop for DepthGuard {
44 #[inline]
45 fn drop(&mut self) {
46 if self.0 != Self::NOOP {
47 DISPATCH_DEPTH.with(|d| d.set(self.0));
48 }
49 }
50}
51
52/// JSONLogic compile/evaluate engine.
53///
54/// Holds the immutable engine state — registered [`crate::CustomOperator`]
55/// implementations, the [`EvaluationConfig`], the optional
56/// preserve-structure flag — and exposes the public surface for parsing
57/// rules ([`Self::compile`]), evaluating them ([`Self::eval`] /
58// `Self::eval_into` is feature-gated on `serde_json`; link it
59// conditionally so default-features `cargo doc` doesn't break.
60#[cfg_attr(
61 feature = "serde_json",
62 doc = "[`Self::eval_str`], [`Self::eval_into`]), and opening hot-loop"
63)]
64#[cfg_attr(
65 not(feature = "serde_json"),
66 doc = "[`Self::eval_str`], `Self::eval_into`), and opening hot-loop"
67)]
68/// sessions ([`Self::session`]).
69// The `trace` feature adds [`Self::trace`]; reference it conditionally so
70// `cargo doc` without `--all-features` doesn't break on the intra-doc link.
71#[cfg_attr(
72 feature = "trace",
73 doc = "Enabling the `trace` feature also exposes [`Self::trace`] for traced sessions."
74)]
75///
76/// `Engine` is `Send + Sync` (every field is); the typical pattern is to
77/// build one at startup, wrap it in `Arc<Engine>`, and clone the `Arc`
78/// across threads or async tasks.
79///
80/// # Example
81///
82/// ```rust
83/// use datalogic_rs::Engine;
84///
85/// // 1. Build the engine.
86/// let engine = Engine::new();
87///
88/// // 2. Compile a rule once.
89/// let logic = engine
90/// .compile(r#"{"if": [{">=": [{"var": "age"}, 18]}, "adult", "minor"]}"#)
91/// .unwrap();
92///
93/// // 3. Evaluate against many inputs (here via `Session::eval_str`;
94/// // drop to `Engine::evaluate` if you want zero-copy borrowed results).
95/// let mut session = engine.session();
96/// for age in [12, 18, 42] {
97/// let payload = format!(r#"{{"age": {age}}}"#);
98/// let result = session.eval_str(&logic, &payload).unwrap();
99/// assert!(result == "\"adult\"" || result == "\"minor\"");
100/// session.reset();
101/// }
102/// ```
103///
104/// # Choosing an evaluate method
105///
106/// **Start here.** Use [`Self::eval_str`] for one-shot calls. Switch
107/// to [`crate::Session`] once you're evaluating the same compiled rule
108/// many times — it reuses one arena instead of allocating per call.
109/// Drop down to [`Self::evaluate`] only when you're managing your own
110/// `bumpalo::Bump` (custom pools, integration with arena-aware
111/// downstream code).
112///
113/// Result-shape suffixes work the same on every tier: `(none)` returns
114/// [`datavalue::OwnedDataValue`], `_str` returns [`String`] (JSON),
115/// `_into::<T>` returns `T: DeserializeOwned` (requires `serde_json`).
116/// The raw [`Self::evaluate`] is the only method that exposes
117/// `&'a DataValue<'a>` and a caller-owned `&Bump`.
118///
119/// Three tiers, in order of caller control:
120///
121/// | Method | Arena ownership | Result type | When to use |
122/// |---|---|---|---|
123// `eval_into` is feature-gated on `serde_json`; emit a linked or plain
124// reference depending on the active features so default-features
125// `cargo doc` doesn't break the table row.
126#[cfg_attr(
127 feature = "serde_json",
128 doc = "| [`Self::eval`] / [`Self::eval_str`] / [`Self::eval_into`] | engine creates a fresh `Bump::with_capacity(4096)` per call | [`OwnedDataValue`](datavalue::OwnedDataValue) / `String` / `T` | One-shot. Any caller that doesn't want to think about arenas. Allocates each call — for hot loops, drop to `Session`. |"
129)]
130#[cfg_attr(
131 not(feature = "serde_json"),
132 doc = "| [`Self::eval`] / [`Self::eval_str`] / `Self::eval_into` | engine creates a fresh `Bump::with_capacity(4096)` per call | [`OwnedDataValue`](datavalue::OwnedDataValue) / `String` / `T` | One-shot. Any caller that doesn't want to think about arenas. Allocates each call — for hot loops, drop to `Session`. |"
133)]
134#[cfg_attr(
135 feature = "serde_json",
136 doc = "| [`crate::Session::eval`] / [`crate::Session::eval_str`] / [`crate::Session::eval_into`] / [`crate::Session::eval_borrowed`] | session-owned `Bump`, caller calls [`crate::Session::reset`] between batches | owned / `String` / `T` / borrowed `&'a DataValue<'a>` | Hot loop with a long-lived engine. The `Session` hides `bumpalo` from the call site and pre-sizes the arena via [`crate::Session::reset_with_capacity`] when needed. |"
137)]
138#[cfg_attr(
139 not(feature = "serde_json"),
140 doc = "| [`crate::Session::eval`] / [`crate::Session::eval_str`] / `Session::eval_into` / [`crate::Session::eval_borrowed`] | session-owned `Bump`, caller calls [`crate::Session::reset`] between batches | owned / `String` / `T` / borrowed `&'a DataValue<'a>` | Hot loop with a long-lived engine. The `Session` hides `bumpalo` from the call site and pre-sizes the arena via [`crate::Session::reset_with_capacity`] when needed. |"
141)]
142/// | [`Self::evaluate`] | caller-passed `&Bump`; library never resets | `&'a DataValue<'a>` (borrowed) | Zero-copy result paths, custom pool/allocator strategies, integration with arena-aware downstream code. |
143///
144/// All routes share the same dispatcher; the differences are who owns
145/// the arena, what the result type looks like, and whether the
146/// boundary parses / serialises JSON for you. There is no perf
147/// difference between the arena-aware paths once the bump is warm.
148///
149/// See the crate-level docs for the two-phase architecture, threading
150/// model, and walk-through examples; see the `Session` and
151/// `EvaluationConfig` rustdoc for arena-management and behaviour-tuning
152/// options respectively.
153pub struct Engine {
154 /// Custom `CustomOperator` implementations registered with the engine.
155 pub(super) custom_operators: HashMap<String, Box<dyn crate::CustomOperator>>,
156 /// Whether templating mode is enabled — multi-key objects compile
157 /// to output-shaping templates and unknown operator keys pass through.
158 #[cfg(feature = "templating")]
159 templating: bool,
160 /// Whether `Engine::compile` runs the constant-folding pass.
161 /// Defaults to `true`; toggled via
162 /// [`crate::EngineBuilder::with_constant_folding`]. The trace surface
163 /// always disables folding regardless of this flag (handled in
164 /// `TracedSession`).
165 constant_folding: bool,
166 /// Configuration for evaluation behavior
167 config: EvaluationConfig,
168}
169
170mod dispatch;
171
172/// Cold fallback for `CompiledNode::Value { lit: None, .. }` — only
173/// reached by ad-hoc `synthetic_value` wrappers (test helpers, trace nodes
174/// built outside `Logic::new`). Outlined so the inliner doesn't
175/// Convert an `OwnedDataValue` to an arena-resident `DataValue` reference.
176/// Trivial cases (Null, Bool, empties) hit shared singletons with no
177/// allocation; non-empty Strings allocate a single `DataValue` wrapper into
178/// the per-call arena (the `&str` is borrowed from the owned source);
179/// non-empty Arrays/Objects deep-convert via `value.to_arena`.
180#[inline]
181fn literal_fallback<'a>(
182 value: &'a datavalue::OwnedDataValue,
183 arena: &'a bumpalo::Bump,
184) -> &'a crate::arena::DataValue<'a> {
185 use datavalue::OwnedDataValue;
186 match value {
187 OwnedDataValue::Null => crate::arena::singletons::singleton_null(),
188 OwnedDataValue::Bool(b) => crate::arena::singletons::singleton_bool(*b),
189 OwnedDataValue::String(s) if s.is_empty() => {
190 crate::arena::singletons::singleton_empty_string()
191 }
192 OwnedDataValue::Array(a) if a.is_empty() => {
193 crate::arena::singletons::singleton_empty_array()
194 }
195 OwnedDataValue::Object(o) if o.is_empty() => {
196 crate::arena::singletons::singleton_empty_object()
197 }
198 OwnedDataValue::String(s) => arena.alloc(crate::arena::DataValue::String(s.as_str())),
199 _ => arena.alloc(value.to_arena(arena)),
200 }
201}
202
203impl Default for Engine {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209impl std::fmt::Debug for Engine {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 // Print the operator *count* rather than names: names are user
212 // registration data, and `Engine::custom_operator_names()` exposes
213 // them already for callers who want them. The trait objects
214 // themselves can't render a meaningful Debug.
215 let mut s = f.debug_struct("Engine");
216 s.field("custom_operators", &self.custom_operators.len());
217 #[cfg(feature = "templating")]
218 s.field("templating", &self.templating);
219 s.field("config", &self.config);
220 s.finish_non_exhaustive()
221 }
222}
223
224impl Engine {
225 /// Start a [`crate::EngineBuilder`] for fluent construction.
226 ///
227 /// Use the builder when you need a non-default [`EvaluationConfig`],
228 /// templating mode, or pre-registered custom operators.
229 /// For a stock engine, [`Self::new`] is shorter.
230 #[inline]
231 pub fn builder() -> crate::EngineBuilder {
232 crate::EngineBuilder::new()
233 }
234
235 /// Open a [`crate::Session`] handle that owns a reusable arena and
236 /// returns owned results, so callers don't need to manage a
237 /// [`bumpalo::Bump`] themselves.
238 ///
239 /// Use this when you want the throughput of arena reuse without the
240 /// lifetime juggling of [`Self::evaluate`]. The arena resets at the start
241 /// of every `eval*` call; results are deep-cloned out of the arena
242 /// before returning, so they outlive the next reset.
243 ///
244 /// # Example
245 ///
246 /// ```rust
247 /// use datalogic_rs::Engine;
248 ///
249 /// let engine = Engine::new();
250 /// let compiled = engine.compile(r#"{"+": [{"var": "x"}, 1]}"#).unwrap();
251 /// let mut session = engine.session();
252 /// let result = session.eval_str(&compiled, r#"{"x": 41}"#).unwrap();
253 /// assert_eq!(result, "42");
254 /// ```
255 #[inline]
256 pub fn session(&self) -> crate::Session<'_> {
257 crate::Session::new(self)
258 }
259
260 /// Internal seam used by the builder. `pub(crate)` is enough — no
261 /// `#[doc(hidden)]` needed since it's not externally reachable.
262 #[inline]
263 pub(crate) fn from_builder_parts(
264 config: EvaluationConfig,
265 _templating: bool,
266 constant_folding: bool,
267 operators: HashMap<String, Box<dyn crate::CustomOperator>>,
268 ) -> Self {
269 Self {
270 custom_operators: operators,
271 #[cfg(feature = "templating")]
272 templating: _templating,
273 constant_folding,
274 config,
275 }
276 }
277
278 /// Creates a new Engine engine with all built-in operators.
279 ///
280 /// The engine includes 50+ built-in operators optimized with OpCode dispatch.
281 /// Templating mode is disabled by default. For non-default
282 /// configuration (custom [`EvaluationConfig`], templating mode,
283 /// pre-registered custom operators) prefer [`Self::builder`].
284 ///
285 /// # Example
286 ///
287 /// ```rust
288 /// use datalogic_rs::Engine;
289 ///
290 /// let engine = Engine::new();
291 /// ```
292 pub fn new() -> Self {
293 Self::from_builder_parts(EvaluationConfig::default(), false, true, HashMap::new())
294 }
295
296 /// Gets a reference to the current evaluation configuration.
297 pub fn config(&self) -> &EvaluationConfig {
298 &self.config
299 }
300
301 /// Internal: whether the constant-folding pass runs during
302 /// [`Self::compile`]. Reads the field set by
303 /// [`crate::EngineBuilder::with_constant_folding`].
304 #[inline]
305 pub(crate) fn constant_folding_enabled(&self) -> bool {
306 self.constant_folding
307 }
308
309 /// Internal: whether templating mode is on. Always returns `false`
310 /// when the crate is built without `feature = "templating"` (the
311 /// underlying field doesn't exist off-feature). Folded here so the
312 /// single call site in `compile/` doesn't repeat the `#[cfg]` ceremony.
313 #[inline]
314 pub(crate) fn is_templating_enabled(&self) -> bool {
315 #[cfg(feature = "templating")]
316 {
317 self.templating
318 }
319 #[cfg(not(feature = "templating"))]
320 {
321 false
322 }
323 }
324
325 /// Checks if a custom operator with the given name is registered.
326 ///
327 /// Operator registration is builder-only; this is a read-only check
328 /// against the frozen set produced by [`crate::EngineBuilder`].
329 pub fn has_custom_operator(&self, name: &str) -> bool {
330 self.custom_operators.contains_key(name)
331 }
332
333 /// Iterator over the names of every *custom* operator registered on
334 /// this engine (built-ins are not included). Order is unspecified
335 /// (HashMap iteration order). Useful for tooling, UIs, and tests
336 /// that need to introspect what's available.
337 pub fn custom_operator_names(&self) -> impl Iterator<Item = &str> {
338 self.custom_operators.keys().map(String::as_str)
339 }
340
341 // ============================================================
342 // V5 PUBLIC API
343 // - One-shot: `eval` / `eval_str` / `eval_into` (engine-owned arena per call)
344 // - Power tier: `evaluate(&Logic, D, &Bump)` (caller-owned arena, borrowed result)
345 // - Hot loop: `engine.session().eval*(...)` (pooled arena, manual reset)
346 // - Trace: `engine.trace().eval*(...)` (Session mirror with TracedRun<R>)
347 // ============================================================
348
349 /// Compile a rule source into reusable [`Logic`].
350 ///
351 /// `rule` accepts any [`crate::IntoLogic`] shape: `&str` (JSON-parsed),
352 /// `&OwnedDataValue` / `OwnedDataValue` (cloned/moved), or
353 /// `&serde_json::Value` (gated on `serde_json`). For cross-thread
354 /// sharing prefer [`Self::compile_arc`].
355 ///
356 /// # Example
357 ///
358 /// ```rust
359 /// use datalogic_rs::Engine;
360 ///
361 /// let engine = Engine::new();
362 /// let compiled = engine.compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
363 /// ```
364 pub fn compile<R: crate::IntoLogic>(&self, rule: R) -> Result<Logic> {
365 let owned = rule.into_owned_logic()?;
366 Logic::compile_with(&owned, self)
367 }
368
369 /// Compile and wrap in an [`Arc`](std::sync::Arc) in one call. Convenience for the
370 /// dominant cross-thread-sharing pattern; equivalent to
371 /// `Arc::new(engine.compile(rule)?)`.
372 pub fn compile_arc<R: crate::IntoLogic>(&self, rule: R) -> Result<std::sync::Arc<Logic>> {
373 Ok(std::sync::Arc::new(self.compile(rule)?))
374 }
375
376 /// Open a [`crate::TracedSession`] over this engine. Calls made through
377 /// the session collect a per-call trace; the bare `eval*` methods on
378 /// `Engine` itself pay no trace overhead.
379 ///
380 /// Available only when the crate is built with `feature = "trace"`.
381 ///
382 /// # Trace coverage
383 ///
384 /// The session's one-shot [`crate::TracedSession::eval_str`] compiles
385 /// the rule internally with optimization disabled, so every operator
386 /// in the rule surfaces a trace step.
387 ///
388 /// The pre-compiled paths ([`crate::TracedSession::eval`] taking a
389 /// `&Logic`) inherit whatever shape that `Logic` was compiled into —
390 /// constant sub-expressions folded by [`Self::compile`] won't appear,
391 /// since there is no operator left to execute. Use `eval_str` for
392 /// full coverage on a one-shot run.
393 ///
394 /// # Example
395 ///
396 /// ```rust
397 /// # #[cfg(feature = "trace")] {
398 /// use datalogic_rs::Engine;
399 ///
400 /// let engine = Engine::new();
401 /// let run = engine
402 /// .trace()
403 /// .eval_str(r#"{"+": [1, 2]}"#, "null");
404 /// assert_eq!(run.result.unwrap(), "3");
405 /// // run.steps is the per-node execution log;
406 /// // run.expression_tree is the rule's compile-time tree shape.
407 /// # }
408 /// ```
409 #[cfg(feature = "trace")]
410 #[cfg_attr(docsrs, doc(cfg(feature = "trace")))]
411 #[inline]
412 pub fn trace(&self) -> crate::trace::TracedSession<'_> {
413 crate::trace::TracedSession::new(self)
414 }
415
416 /// Evaluate compiled logic against arena-resident data — **raw tier**.
417 ///
418 /// The caller owns the [`bumpalo::Bump`] lifecycle and may `reset()`
419 /// it between calls; the returned `&DataValue<'a>` borrows from the
420 /// arena, so it must be dropped before the next reset (enforced by
421 /// the borrow checker). For ergonomic owned/typed/JSON-string output,
422 /// prefer [`Self::eval`] / [`Self::eval_str`]
423 // `Self::eval_into` is gated behind `serde_json`; link conditionally.
424 #[cfg_attr(feature = "serde_json", doc = "/ [`Self::eval_into`].")]
425 #[cfg_attr(
426 not(feature = "serde_json"),
427 doc = "(plus `Self::eval_into` with the `serde_json` feature)."
428 )]
429 ///
430 /// # Example
431 ///
432 /// ```rust
433 /// use bumpalo::Bump;
434 /// use datalogic_rs::{Engine, DataValue};
435 ///
436 /// let engine = Engine::new();
437 /// let compiled = engine.compile(r#"{"+": [{"var": "x"}, 2]}"#).unwrap();
438 ///
439 /// let arena = Bump::new();
440 /// let data = DataValue::from_str(r#"{"x": 40}"#, &arena).unwrap();
441 /// let result = engine.evaluate(&compiled, data, &arena).unwrap();
442 /// assert_eq!(result.as_i64(), Some(42));
443 /// ```
444 ///
445 /// `data` accepts any input shape understood by [`crate::EvalInput`]:
446 /// `&'a DataValue<'a>` (zero-cost passthrough), `DataValue<'a>`
447 /// (single arena alloc), `&str` (JSON-parsed), `&OwnedDataValue`
448 /// (deep-borrowed), or `&serde_json::Value` (gated on `serde_json`).
449 #[inline(always)]
450 pub fn evaluate<'a, D: crate::EvalInput<'a>>(
451 &self,
452 compiled: &'a Logic,
453 data: D,
454 arena: &'a bumpalo::Bump,
455 ) -> Result<&'a crate::arena::DataValue<'a>> {
456 let _depth_guard = self.enter_dispatch_boundary()?;
457 let data_ref = data.into_arena_value(arena)?;
458 let mut ctx = crate::arena::ContextStack::new(data_ref);
459 match self.dispatch_node(&compiled.root, &mut ctx, arena) {
460 Ok(av) => Ok(av),
461 Err(e) => Err(e.decorated(ctx.take_error_path(), compiled, true)),
462 }
463 }
464
465 /// One-shot evaluation returning [`datavalue::OwnedDataValue`].
466 ///
467 /// Compiles `rule`, parses `data`, evaluates against a fresh
468 /// per-call arena, and deep-clones the result out. For the same
469 /// rule run repeatedly, escalate to [`Self::compile`] + a
470 /// [`Session`](crate::Session).
471 ///
472 /// # Example
473 ///
474 /// ```rust
475 /// use datalogic_rs::Engine;
476 ///
477 /// let engine = Engine::new();
478 /// let result = engine.eval(
479 /// r#"{"+": [{"var": "x"}, 1]}"#,
480 /// r#"{"x": 41}"#,
481 /// ).unwrap();
482 /// assert_eq!(result.as_i64(), Some(42));
483 /// ```
484 pub fn eval<R, D>(&self, rule: R, data: D) -> Result<datavalue::OwnedDataValue>
485 where
486 R: crate::IntoLogic,
487 D: crate::OwnedInput,
488 {
489 self.eval_with::<datavalue::OwnedDataValue, _, _>(rule, data)
490 }
491
492 /// One-shot evaluation returning a JSON [`String`].
493 ///
494 /// # Example
495 ///
496 /// ```rust
497 /// use datalogic_rs::Engine;
498 ///
499 /// let engine = Engine::new();
500 /// let result = engine.eval_str(
501 /// r#"{"==": [{"var": "x"}, 5]}"#,
502 /// r#"{"x": 5}"#,
503 /// ).unwrap();
504 /// assert_eq!(result, "true");
505 /// ```
506 pub fn eval_str<R, D>(&self, rule: R, data: D) -> Result<String>
507 where
508 R: crate::IntoLogic,
509 D: crate::OwnedInput,
510 {
511 self.eval_with::<String, _, _>(rule, data)
512 }
513
514 /// One-shot evaluation deserialised into a typed `T: DeserializeOwned`.
515 ///
516 /// Use `T = serde_json::Value` for a JSON `Value` result; use a typed
517 /// struct for direct mapping. Internally routes through `serde_json`
518 /// (round-trips the result through a JSON value).
519 ///
520 /// # Example
521 ///
522 /// ```rust
523 /// # #[cfg(feature = "serde_json")] {
524 /// use datalogic_rs::Engine;
525 /// use serde_json::Value;
526 ///
527 /// let engine = Engine::new();
528 /// let result: Value = engine.eval_into(
529 /// r#"{"+": [{"var": "x"}, 1]}"#,
530 /// r#"{"x": 41}"#,
531 /// ).unwrap();
532 /// assert_eq!(result, Value::from(42));
533 /// # }
534 /// ```
535 #[cfg(feature = "serde_json")]
536 #[cfg_attr(docsrs, doc(cfg(feature = "serde_json")))]
537 pub fn eval_into<T, R, D>(&self, rule: R, data: D) -> Result<T>
538 where
539 T: serde::de::DeserializeOwned,
540 R: crate::IntoLogic,
541 D: crate::OwnedInput,
542 {
543 let value: serde_json::Value = self.eval_with(rule, data)?;
544 serde_json::from_value(value).map_err(crate::Error::from)
545 }
546
547 /// Internal generic shared by `eval` / `eval_str` / `eval_into`.
548 /// Compiles, allocates a fresh per-call arena, evaluates, and
549 /// projects the result through [`crate::FromDataValue`].
550 fn eval_with<O, R, D>(&self, rule: R, data: D) -> Result<O>
551 where
552 O: crate::FromDataValue,
553 R: crate::IntoLogic,
554 D: crate::OwnedInput,
555 {
556 let compiled = self.compile(rule)?;
557 // 4 KB initial capacity covers typical small-rule evaluations.
558 let arena = bumpalo::Bump::with_capacity(4096);
559 let owned_data = data.into_owned_input()?;
560 let result = self.evaluate(&compiled, &owned_data, &arena)?;
561 O::from_arena(result)
562 }
563
564 /// Bump the per-thread dispatch-boundary depth counter, bailing with
565 /// `ConfigurationError` if the configured cap is reached. Returns a
566 /// guard that decrements the counter on drop (covers `?` early returns
567 /// and panics).
568 ///
569 /// Called from every public boundary entry point (`Engine::evaluate`,
570 /// `TracedSession::evaluate`, …). The counter is thread-local rather
571 /// than per-`ContextStack` so it survives across nested
572 /// `engine.evaluate(...)` calls — the scenario a `CustomOperator`
573 /// holding `Arc<Engine>` creates by re-entering the engine from inside
574 /// its own `evaluate(...)`.
575 ///
576 /// Tokio safety: dispatch is sync, so a task cannot `.await` while the
577 /// counter is raised; between dispatches the value is restored to its
578 /// prior level (zero at the outermost call). Cross-thread task migration
579 /// thus starts fresh on whatever thread the task lands on.
580 #[inline(always)]
581 pub(crate) fn enter_dispatch_boundary(&self) -> Result<DepthGuard> {
582 // Built-in operators can't re-enter `Engine::evaluate` (only a
583 // `CustomOperator` holding `Arc<Engine>` can); when the registry
584 // is empty, cross-evaluate recursion is impossible and we skip
585 // the TLS bookkeeping. The pure-built-in benchmarks pay zero.
586 if self.custom_operators.is_empty() {
587 return Ok(DepthGuard(DepthGuard::NOOP));
588 }
589 self.enter_dispatch_boundary_checked()
590 }
591
592 /// Slow path of [`Self::enter_dispatch_boundary`] — hit only when
593 /// the engine has at least one custom operator registered. Marked
594 /// `#[cold]` and `#[inline(never)]` so the hot fast-path stays
595 /// inline-friendly.
596 #[cold]
597 #[inline(never)]
598 fn enter_dispatch_boundary_checked(&self) -> Result<DepthGuard> {
599 let prev_depth = DISPATCH_DEPTH.with(Cell::get);
600 if prev_depth >= self.config.max_recursion_depth {
601 return Err(crate::Error::configuration_error(format!(
602 "max recursion depth exceeded ({})",
603 self.config.max_recursion_depth
604 )));
605 }
606 DISPATCH_DEPTH.with(|d| d.set(prev_depth + 1));
607 Ok(DepthGuard(prev_depth))
608 }
609
610 /// Arena-mode dispatch hub. Returns `&'a DataValue<'a>` for every
611 /// `CompiledNode` shape — exhaustive match, no value-mode fallback.
612 ///
613 /// On error, accumulates the failing node's id onto the context stack's
614 /// breadcrumb so [`Error`] consumers can surface the failing
615 /// path. When a tracer is attached to `ctx`, records a step per
616 /// non-literal node (entry context + result/error).
617 #[inline(always)]
618 pub(crate) fn dispatch_node<'a>(
619 &self,
620 node: &'a CompiledNode,
621 ctx: &mut crate::arena::ContextStack<'a>,
622 arena: &'a bumpalo::Bump,
623 ) -> Result<&'a crate::arena::DataValue<'a>> {
624 // Literal fast path — no breadcrumb push, no trace step.
625 if let CompiledNode::Value { value, lit, .. } = node {
626 // Trivial literals (Null/Bool/Number/empty primitives) are
627 // pre-built `DataValue<'static>` by `precompute_lit` at node
628 // construction; covariance lets `&'a DataValue<'static>`
629 // satisfy `&'a DataValue<'a>` directly.
630 if let Some(av) = lit {
631 return Ok(av);
632 }
633 // Non-trivial literals (non-empty Strings/Arrays/Objects) and
634 // synthetic nodes built outside the compile pipeline fall
635 // through to per-call arena allocation here.
636 return Ok(literal_fallback(value, arena));
637 }
638
639 // Snapshot context for trace BEFORE recursing — children will
640 // mutate iteration frames. Cheap when no tracer is attached.
641 #[cfg(feature = "trace")]
642 let ctx_snapshot: Option<serde_json::Value> =
643 ctx.has_tracer().then(|| ctx.current_data_as_value());
644
645 let result = dispatch::dispatch_node_inner(self, node, ctx, arena);
646
647 // Accumulate the failing node's id on every Err. We always pay
648 // the (single) Vec::push since errors are rare and structured-error
649 // consumers need the breadcrumb.
650 if result.is_err() {
651 ctx.push_error_step(node.id());
652 }
653
654 #[cfg(feature = "trace")]
655 if let Some(ctx_data) = ctx_snapshot {
656 ctx.record_node_result(node.id(), ctx_data, &result);
657 }
658
659 result
660 }
661
662 /// Evaluate an iteration body (map/filter/reduce/all/some/none) with the
663 /// trace collector's iteration index/total markers set around it. The
664 /// markers are no-ops when no tracer is attached, so plain-mode callers
665 /// pay only one branch per iteration.
666 #[inline]
667 pub(crate) fn run_iter_body<'a>(
668 &self,
669 body: &'a CompiledNode,
670 ctx: &mut crate::arena::ContextStack<'a>,
671 arena: &'a bumpalo::Bump,
672 _index: u32,
673 _total: u32,
674 ) -> Result<&'a crate::arena::DataValue<'a>> {
675 #[cfg(feature = "trace")]
676 ctx.trace_push_iteration(_index, _total);
677 let res = self.dispatch_node(body, ctx, arena);
678 #[cfg(feature = "trace")]
679 ctx.trace_pop_iteration();
680 res
681 }
682}