Skip to main content

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}