datalogic-rs 5.1.0

High-performance JSONLogic (json-logic) rules engine and sandboxed expression evaluator in Rust — one core, official bindings for Node.js, WASM, Python, Go, Java, .NET, and PHP. Compile once, evaluate in nanoseconds.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! `Logic` — the compiled, thread-safe rule snapshot returned by
//! `Engine::compile`. Includes the static-evaluation predicates the compiler
//! consults to decide whether a sub-expression can be folded.

use super::{CompiledNode, populate_lits};
use crate::opcode::OpCode;

/// Compiled logic that can be evaluated multiple times across different data.
///
/// `Logic` represents a pre-processed JSONLogic expression that has been
/// optimized for repeated evaluation. It's thread-safe and can be shared across
/// threads using `Arc`.
///
/// # Performance Benefits
///
/// - **Parse once, evaluate many**: Avoid repeated JSON parsing
/// - **Static evaluation**: Constant expressions are pre-computed
/// - **OpCode dispatch**: Built-in operators use fast enum dispatch
/// - **Thread-safe sharing**: Use `Arc` to share across threads
///
/// # Example
///
/// ```rust
/// use std::sync::Arc;
/// use datalogic_rs::Engine;
///
/// let engine = Engine::new();
/// let compiled = Arc::new(engine.compile(r#"{">": [{"var": "score"}, 90]}"#).unwrap());
///
/// // Compiled logic can be cloned cheaply (atomic refcount) and sent across threads.
/// let compiled_clone = Arc::clone(&compiled);
/// std::thread::spawn(move || {
///     let engine = Engine::new();
///     let _result = engine
///         .session()
///         .eval_str(&compiled_clone, r#"{"score": 95}"#)
///         .unwrap();
/// });
/// ```
///
/// `Logic` is `Clone` (deep-clones the compiled tree). Cloning is the right
/// choice when a caller needs an independently mutable copy or wants to
/// store the rule by value; for sharing the *same* compiled rule across
/// threads or evaluations, prefer `Arc<Logic>` — the `Arc::clone` is a
/// single atomic refcount bump rather than a tree walk.
#[derive(Clone)]
pub struct Logic {
    /// The root node of the compiled logic tree.
    pub(crate) root: CompiledNode,
    /// Pre-resolved operator name for the root node, attached to every
    /// `Error` returned from the public `evaluate*` API. Cached at compile
    /// time so the error-unwind path does no tree walk. `Cow::Borrowed`
    /// for built-ins (zero alloc on attach), `Cow::Owned` for
    /// `CustomOperator` (one alloc per compile, amortised over many
    /// evaluations), `None` for `Value` literals.
    pub(crate) root_op_name: Option<std::borrow::Cow<'static, str>>,
    /// Number of CSE memo slots assigned by the compile-time CSE pass
    /// (`crate::compile::optimize::cse`); the upper bound of the lazily
    /// grown per-evaluation slot table on `ContextStack`. `0` for rules
    /// with no shared pure aggregates (and for every no-fold / traced
    /// compile). Plain `Copy` data — `Logic` stays `Send + Sync` with no
    /// interior mutability.
    pub(crate) cse_slot_count: u16,
}

impl std::fmt::Debug for Logic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Logic")
            .field("root", &self.root)
            .field("root_op_name", &self.root_op_name)
            .finish_non_exhaustive()
    }
}

impl Logic {
    /// Creates a new compiled logic from a root node.
    ///
    /// Caches per-operator analysis results onto every `BuiltinOperator`
    /// node and pre-builds every literal onto its `Value` node. Trivial
    /// literals (Null/Bool/Number/empty) are pre-built by
    /// [`super::populate::precompute_lit`] at construction; non-trivial
    /// literals (non-empty Strings/Arrays/Objects) by the
    /// [`super::populate::populate_lits`] pass here, so dispatch returns a
    /// borrow instead of re-converting the literal per evaluation.
    ///
    /// # Arguments
    ///
    /// * `root` - The root node of the compiled logic tree
    /// * `cse_slot_count` - Memo slots assigned by the CSE pass (0 when
    ///   the pass didn't run or found nothing to share)
    pub(crate) fn new(mut root: CompiledNode, cse_slot_count: u16) -> Self {
        populate_lits(&mut root);
        let root_op_name = root.operator_name();
        Self {
            root,
            root_op_name,
            cse_slot_count,
        }
    }

    /// Check if this compiled logic is static (can be evaluated without context)
    pub fn is_static(&self) -> bool {
        node_is_static(&self.root)
    }

    /// Check if compilation reduced this rule to a compile-time constant.
    ///
    /// The compiler constant-folds every static sub-expression it can
    /// prove, so a rule with no data dependency usually compiles down to a
    /// single literal node. `is_constant` reports whether that happened
    /// for the *whole* rule: evaluating a constant rule returns the
    /// pre-computed value without executing any operator, so its cost is
    /// literal-return overhead, not engine work.
    ///
    /// Contrast with [`Self::is_static`]: `is_static` asks whether the
    /// tree *could* be evaluated without a data context, while
    /// `is_constant` reports whether the compiler actually *did* collapse
    /// the root to a literal. The two can differ; for example
    /// `{"/": [1, 0]}` is static, but folding it fails (division by zero
    /// errors under the default configuration), so the operator node is
    /// kept and the error surfaces at evaluation time. Benchmarks and
    /// rule-analysis tooling use this accessor to separate folded rules
    /// from genuinely data-dependent ones.
    ///
    /// # Example
    ///
    /// ```rust
    /// use datalogic_rs::Engine;
    ///
    /// let engine = Engine::new();
    ///
    /// // No data dependency: the compiler folds `1 + 2` to the literal `3`.
    /// let folded = engine.compile(r#"{"+": [1, 2]}"#).unwrap();
    /// assert!(folded.is_constant());
    ///
    /// // Reads the data context, so it stays an operator node.
    /// let dynamic = engine.compile(r#"{"var": "x"}"#).unwrap();
    /// assert!(!dynamic.is_constant());
    /// ```
    pub fn is_constant(&self) -> bool {
        matches!(self.root, CompiledNode::Value { .. })
    }

    /// Number of shared-subexpression memo slots the compiler assigned to
    /// this rule.
    ///
    /// The compile-time CSE pass detects structurally identical pure
    /// subtrees (typically repeated aggregates — JSONLogic has no `let`
    /// bindings, so rule authors paste them) and arranges for each
    /// equivalence class to be computed once per evaluation. `0` means no
    /// shared subexpressions were found — or the pass didn't run (engines
    /// built with [`crate::EngineBuilder::with_constant_folding`]`(false)`
    /// or configured with a [`crate::TruthyEvaluator::Custom`] evaluator
    /// compile without CSE). Useful for rule-analysis tooling and for
    /// verifying that a hot rule benefits from sharing.
    ///
    /// # Example
    ///
    /// ```rust
    /// use datalogic_rs::Engine;
    ///
    /// let engine = Engine::new();
    /// let agg = r#"{"reduce": [{"var": "xs"}, {"+": [{"var": "accumulator"}, {"var": "current"}]}, 0]}"#;
    /// let rule = format!(r#"{{"+": [{agg}, {agg}]}}"#);
    /// let compiled = engine.compile(rule.as_str()).unwrap();
    /// assert_eq!(compiled.cse_slot_count(), 1);
    /// ```
    pub fn cse_slot_count(&self) -> u16 {
        self.cse_slot_count
    }

    /// Reconstruct a JSONLogic string from this compiled tree.
    ///
    /// Reflects the *compiled* shape — constant-folded sub-expressions
    /// appear as literals, since the original operator is gone by then.
    /// Re-parsing the output through [`crate::Engine::compile`] yields a
    /// `Logic` that evaluates identically. Useful for caching keys, identity
    /// checks across compiled rules, debug logging, and tooling.
    ///
    /// `Var` nodes serialise to `{"var": "..."}` for `scope_level == 0`
    /// and to `{"val": [[<level>], ...]}` for `scope_level > 0` — that's
    /// the shape the compiler accepts on round-trip.
    ///
    /// # Example
    ///
    /// ```rust
    /// use datalogic_rs::Engine;
    ///
    /// let engine = Engine::new();
    /// let compiled = engine.compile(r#"{">": [{"var": "score"}, 90]}"#).unwrap();
    /// let json = compiled.to_json();
    /// assert!(json.contains(r#""var": "score""#));
    ///
    /// // Round-trip: re-compiling the output produces an equivalent rule.
    /// let recompiled = engine.compile(&json).unwrap();
    /// assert_eq!(
    ///     engine.eval_str(&json, r#"{"score": 95}"#).unwrap(),
    ///     "true",
    /// );
    /// # let _ = (compiled, recompiled);
    /// ```
    pub fn to_json(&self) -> String {
        crate::node_serialize::node_to_json_string(&self.root)
    }
}

impl std::fmt::Display for Logic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.to_json())
    }
}

/// Check if a compiled node is static (can be evaluated without runtime context).
pub(crate) fn node_is_static(node: &CompiledNode) -> bool {
    match node {
        CompiledNode::Value { .. } => true,
        CompiledNode::Array { nodes, .. } => nodes.iter().all(node_is_static),
        CompiledNode::BuiltinOperator { opcode, args, .. } => opcode_is_static(opcode, args),
        CompiledNode::CustomOperator(_) => false,
        CompiledNode::Cse(data) => node_is_static(&data.inner),
        CompiledNode::Var { .. } => false,
        #[cfg(feature = "ext-control")]
        CompiledNode::Exists(_) => false,
        #[cfg(feature = "error-handling")]
        CompiledNode::Throw(_) => false,
        #[cfg(feature = "templating")]
        CompiledNode::StructuredObject(data) => {
            data.fields.iter().all(|(_, node)| node_is_static(node))
        }
        CompiledNode::Missing(_) | CompiledNode::MissingSome(_) => false,
        // InvalidArgs is dynamic — it raises an error at runtime.
        CompiledNode::InvalidArgs { .. } => false,
    }
}

/// Check if an operator can be statically evaluated at compile time.
///
/// Static operators can be pre-computed during compilation when their arguments
/// are also static, eliminating runtime evaluation overhead.
///
/// # Classification Criteria
///
/// An operator is **non-static** (dynamic) if it:
/// 1. Reads from the data context (`var`, `val`, `missing`, `exists`)
/// 2. Uses iterative callbacks with changing context (`map`, `filter`, `reduce`)
/// 3. Has side effects or error handling (`try`, `throw`)
/// 4. Depends on runtime state (`now` for current time)
/// 5. Needs runtime disambiguation (`merge`, `min`, `max`)
///
/// All other operators are **static** when their arguments are static.
fn opcode_is_static(opcode: &OpCode, args: &[CompiledNode]) -> bool {
    use OpCode::*;

    // Check if all arguments are static first (common pattern)
    let args_static = || args.iter().all(node_is_static);

    match opcode {
        // Context-dependent: These operators read from the data context, which is
        // not available at compile time. They must remain dynamic.
        Val | Missing | MissingSome => false,
        #[cfg(feature = "ext-control")]
        Exists => false,

        // Iteration operators: These push new contexts for each iteration and use
        // callbacks that may reference the iteration variable. Even with static
        // arrays, the callback logic depends on the per-element context.
        Map | Filter | Reduce | All | Some | None => false,

        // Error handling: These have control flow effects (early exit, error propagation)
        // that should be preserved for runtime execution.
        #[cfg(feature = "error-handling")]
        Try | Throw => false,

        // Time-dependent: Returns current UTC time, inherently non-static.
        #[cfg(feature = "datetime")]
        Now => false,

        // Context-dependent in implicit form: when the bucketing
        // expression is omitted, `fractional` reads `$flagd.flagKey` and
        // `targetingKey` from the root data, so it cannot be folded even
        // if every literal arg is static. Even the explicit form depends
        // on the user expecting it to be evaluated per-call (the same
        // input always produces the same output, but folding bakes in
        // *one* bucketing key for the lifetime of the compiled rule —
        // which is correct, but surprises users who rebuild the rule
        // with a different bucketing strategy). Keep dynamic.
        #[cfg(feature = "flagd")]
        Fractional => false,
        // `sem_ver` is pure given static args — `Version::parse` +
        // comparison has no context dependency. Fold when every arg
        // (version1, op, version2) is a literal. The common case is
        // `sem_ver(var("app_version"), ">=", "1.2.0")` which has a
        // dynamic var and stays dynamic naturally.
        #[cfg(feature = "flagd")]
        SemVer => args_static(),

        // Runtime disambiguation needed: Merge/Min/Max have to distinguish
        // a [1,2,3] literal from operator arguments at runtime to handle
        // nested arrays correctly.
        Merge | Min | Max => false,

        // Pure operators: Static when all arguments are static. These perform
        // deterministic transformations without side effects or context access.
        _ => args_static(),
    }
}

#[cfg(test)]
mod tests {
    use crate::Engine;

    #[test]
    fn is_constant_tracks_folding() {
        let engine = Engine::new();

        // Static expressions fold all the way down to a literal.
        let folded = engine.compile(r#"{"+": [1, {"*": [2, 3]}]}"#).unwrap();
        assert!(folded.is_constant());
        assert!(folded.is_static());

        // Bare literals (including composite ones) compile to Value nodes.
        assert!(engine.compile("42").unwrap().is_constant());
        assert!(engine.compile("[1, 2, 3]").unwrap().is_constant());

        // Data-dependent rules stay operator nodes.
        let dynamic = engine.compile(r#"{"var": "x"}"#).unwrap();
        assert!(!dynamic.is_constant());
        assert!(!dynamic.is_static());

        // `merge` needs runtime disambiguation, so it is classified
        // non-static and never folded even with literal args.
        assert!(
            !engine
                .compile(r#"{"merge": [[1], [2]]}"#)
                .unwrap()
                .is_constant()
        );

        // Static but not constant: folding `1 / 0` fails (NaN error under
        // the default config), so the operator node is kept and the error
        // is deferred to evaluation time.
        let div = engine.compile(r#"{"/": [1, 0]}"#).unwrap();
        assert!(div.is_static());
        assert!(!div.is_constant());
    }

    /// Composite literals are pre-built (`PreLit`) at compile time; a
    /// deep `Logic::clone` rebuilds the cells rather than sharing them,
    /// and both copies must evaluate identically even after the original
    /// is dropped.
    #[test]
    fn cloned_logic_keeps_prebuilt_composite_literals() {
        let engine = Engine::new();
        let rule = r#"{"in": [{"var": "x"}, ["a", "b", "c"]]}"#;
        let original = engine.compile(rule).unwrap();
        let cloned = original.clone();
        drop(original);
        assert_eq!(
            engine.eval_str(rule, r#"{"x": "b"}"#).unwrap(),
            "true",
            "sanity: rule matches via one-shot path"
        );
        let mut session = engine.session();
        assert_eq!(session.eval_str(&cloned, r#"{"x": "b"}"#).unwrap(), "true");
        session.reset();
        assert_eq!(session.eval_str(&cloned, r#"{"x": "z"}"#).unwrap(), "false");
    }

    /// A `switch` whose case table folds to a composite literal must still
    /// match its cases. The folded table's `PreLit` powers
    /// `evaluate_switch`'s `Value { lit: Some(..) }` arms — both for a
    /// dynamic discriminant (table folded, switch kept) and for a fully
    /// static switch (table folded, then the whole switch constant-folded
    /// at compile time, which requires the table's prebuilt view to exist
    /// *during* the fold — see `CompiledNode::compile_time_value`).
    #[cfg(feature = "ext-control")]
    #[test]
    fn folded_switch_case_tables_match() {
        let engine = Engine::new();

        // Dynamic discriminant, static (folded) case table.
        let rule = r#"{"switch": [{"var": "x"}, [[1, "one"], [2, "two"]], "dflt"]}"#;
        assert_eq!(engine.eval_str(rule, r#"{"x": 1}"#).unwrap(), "\"one\"");
        assert_eq!(engine.eval_str(rule, r#"{"x": 2}"#).unwrap(), "\"two\"");
        assert_eq!(engine.eval_str(rule, r#"{"x": 3}"#).unwrap(), "\"dflt\"");

        // Fully static switch: constant-folded at compile time.
        let folded = engine
            .compile(r#"{"switch": ["b", [["a", 1], ["b", 2]], 0]}"#)
            .unwrap();
        assert!(folded.is_constant());
        assert_eq!(
            engine.eval_str(folded.to_json().as_str(), "null").unwrap(),
            "2"
        );

        // Mixed table: one static (folded) pair among dynamic ones.
        let mixed = r#"{"switch": [{"var": "x"}, [["s", "static-hit"], [{"var": "k"}, "dyn-hit"]], "none"]}"#;
        assert_eq!(
            engine.eval_str(mixed, r#"{"x": "s", "k": "?"}"#).unwrap(),
            "\"static-hit\""
        );
        assert_eq!(
            engine.eval_str(mixed, r#"{"x": "d", "k": "d"}"#).unwrap(),
            "\"dyn-hit\""
        );
    }
}