Skip to main content

jsonata_core/
lib.rs

1// jsonatapy - High-performance Python implementation of JSONata
2// Copyright (c) 2025 jsonatapy contributors
3// Licensed under the MIT License
4
5//! # jsonatapy
6//!
7//! A high-performance Rust implementation of JSONata - the JSON query and
8//! transformation language - with optional Python bindings via PyO3.
9//!
10//! ## Rust API
11//!
12//! ```rust,ignore
13//! use jsonata_core::parser;
14//! use jsonata_core::evaluator::Evaluator;
15//! use jsonata_core::value::JValue;
16//!
17//! let ast = parser::parse("user.name").unwrap();
18//! let data = JValue::from_json_str(r#"{"user":{"name":"Alice"}}"#).unwrap();
19//! let result = Evaluator::new().evaluate(&ast, &data).unwrap();
20//! ```
21//!
22//! ## Architecture
23//!
24//! - `parser` - Expression parser (converts JSONata strings to AST)
25//! - `evaluator` - Expression evaluator (executes AST against data)
26//! - `functions` - Built-in function implementations
27//! - `datetime` - Date/time handling functions
28//! - `signature` - Function signature validation
29//! - `ast` - Abstract Syntax Tree definitions
30//! - `value` - JValue type (the runtime value representation)
31
32pub mod ast;
33pub mod ast_transform;
34mod compiler;
35mod datetime;
36pub mod evaluator;
37pub mod functions;
38pub mod parser;
39mod signature;
40pub mod value;
41mod vm;
42
43// ── Benchmarking facade (only when the "bench" feature is enabled) ────────────
44//
45// Exposes the compiler/VM pipeline for Criterion benchmarks without making
46// the internals part of the permanent public API.
47
48/// Internal benchmarking API — do not use in production code.
49///
50/// Enabled with `--features bench`. Provides access to the bytecode compiler
51/// and VM so that Criterion benchmarks can measure tree-walker vs VM directly.
52#[cfg(feature = "bench")]
53pub mod _bench {
54    use crate::ast::AstNode;
55    pub use crate::evaluator::EvaluatorError;
56    use crate::value::JValue;
57
58    /// An opaque handle to a compiled bytecode program.
59    pub struct CompiledProgram(crate::vm::BytecodeProgram);
60
61    /// Compile an AST node to bytecode.
62    ///
63    /// Returns `None` if the expression contains constructs the compiler
64    /// doesn't handle (e.g. wildcards, `$eval`, higher-order functions at
65    /// the top level). In that case, fall back to `Evaluator::new().evaluate()`.
66    pub fn compile(ast: &AstNode) -> Option<CompiledProgram> {
67        crate::evaluator::try_compile_expr(ast)
68            .map(|ce| CompiledProgram(crate::compiler::BytecodeCompiler::compile(&ce)))
69    }
70
71    /// Execute a compiled program against `data`.
72    pub fn run(prog: &CompiledProgram, data: &JValue) -> Result<JValue, EvaluatorError> {
73        crate::vm::Vm::new(&prog.0).run(data, None)
74    }
75}
76
77// ── Python bindings (only when the "python" feature is enabled) ───────────────
78
79/// The JSONata reference implementation version this library targets.
80const JSONATA_REFERENCE_VERSION: &str = "2.1.0";
81
82#[cfg(feature = "python")]
83use crate::value::JValue;
84#[cfg(feature = "python")]
85use indexmap::IndexMap;
86#[cfg(feature = "python")]
87use pyo3::exceptions::{PyTypeError, PyValueError};
88#[cfg(feature = "python")]
89use pyo3::prelude::*;
90#[cfg(feature = "python")]
91use pyo3::types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString};
92
93/// Pre-converted data handle for efficient repeated evaluation.
94///
95/// Convert Python data to an internal representation once, then reuse it
96/// across multiple evaluations to avoid repeated Python↔Rust conversion overhead.
97///
98/// # Examples
99///
100/// ```python
101/// import jsonatapy
102///
103/// data = jsonatapy.JsonataData({"orders": [{"price": 150}, {"price": 50}]})
104/// expr = jsonatapy.compile("orders[price > 100]")
105/// result = expr.evaluate_with_data(data)
106/// ```
107#[cfg(feature = "python")]
108#[pyclass(unsendable)]
109struct JsonataData {
110    data: JValue,
111}
112
113#[cfg(feature = "python")]
114#[pymethods]
115impl JsonataData {
116    /// Create from a Python object (dict, list, etc.)
117    #[new]
118    fn new(py: Python, data: Py<PyAny>) -> PyResult<Self> {
119        let jvalue = python_to_json(py, &data)?;
120        Ok(JsonataData { data: jvalue })
121    }
122
123    /// Create from a JSON string (fastest path).
124    #[staticmethod]
125    fn from_json(json_str: &str) -> PyResult<Self> {
126        let data = JValue::from_json_str(json_str)
127            .map_err(|e| PyValueError::new_err(format!("Invalid JSON: {}", e)))?;
128        Ok(JsonataData { data })
129    }
130}
131
132/// A compiled JSONata expression that can be evaluated against data.
133///
134/// This is the main entry point for using JSONata. Compile an expression once,
135/// then evaluate it multiple times against different data.
136///
137/// # Examples
138///
139/// ```python
140/// import jsonatapy
141///
142/// # Compile once
143/// expr = jsonatapy.compile("orders[price > 100].product")
144///
145/// # Evaluate many times
146/// data1 = {"orders": [{"product": "A", "price": 150}]}
147/// result1 = expr.evaluate(data1)
148///
149/// data2 = {"orders": [{"product": "B", "price": 50}]}
150/// result2 = expr.evaluate(data2)
151/// ```
152#[cfg(feature = "python")]
153#[pyclass(unsendable)]
154struct JsonataExpression {
155    /// The parsed Abstract Syntax Tree
156    ast: ast::AstNode,
157    /// Lazily compiled bytecode — populated on first evaluate() call.
158    /// `Some(bc)` = fast VM path; `None` = must use tree-walker.
159    /// `OnceCell` ensures compilation happens at most once per expression instance.
160    bytecode: std::cell::OnceCell<Option<vm::BytecodeProgram>>,
161}
162
163#[cfg(feature = "python")]
164impl JsonataExpression {
165    /// Evaluate the compiled expression against pre-converted data.
166    /// Uses bytecode VM when available, falls back to tree-walker.
167    fn run_eval(&self, py: Python, data: &JValue, bindings: Option<Py<PyAny>>) -> PyResult<JValue> {
168        if bindings.is_none() {
169            let bytecode = self.bytecode.get_or_init(|| {
170                evaluator::try_compile_expr(&self.ast)
171                    .map(|ce| compiler::BytecodeCompiler::compile(&ce))
172            });
173            if let Some(bc) = bytecode {
174                vm::Vm::new(bc)
175                    .run(data, None)
176                    .map_err(evaluator_error_to_py)
177            } else {
178                let mut ev = evaluator::Evaluator::new();
179                ev.evaluate(&self.ast, data).map_err(evaluator_error_to_py)
180            }
181        } else {
182            let mut ev = create_evaluator(py, bindings)?;
183            ev.evaluate(&self.ast, data).map_err(evaluator_error_to_py)
184        }
185    }
186}
187
188#[cfg(feature = "python")]
189#[pymethods]
190impl JsonataExpression {
191    /// Returns ValueError if evaluation fails
192    #[pyo3(signature = (data, bindings=None))]
193    fn evaluate(
194        &self,
195        py: Python,
196        data: Py<PyAny>,
197        bindings: Option<Py<PyAny>>,
198    ) -> PyResult<Py<PyAny>> {
199        let json_data = python_to_json(py, &data)?;
200        json_to_python(py, &self.run_eval(py, &json_data, bindings)?)
201    }
202
203    /// Evaluate with a pre-converted data handle (fastest for repeated evaluation).
204    ///
205    /// # Arguments
206    ///
207    /// * `data` - A JsonataData handle (pre-converted from Python to internal format)
208    /// * `bindings` - Optional additional variable bindings
209    ///
210    /// # Returns
211    ///
212    /// The result of evaluating the expression
213    #[pyo3(signature = (data, bindings=None))]
214    fn evaluate_with_data(
215        &self,
216        py: Python,
217        data: &JsonataData,
218        bindings: Option<Py<PyAny>>,
219    ) -> PyResult<Py<PyAny>> {
220        json_to_python(py, &self.run_eval(py, &data.data, bindings)?)
221    }
222
223    /// Evaluate with a pre-converted data handle, return JSON string (zero-overhead output).
224    ///
225    /// # Arguments
226    ///
227    /// * `data` - A JsonataData handle (pre-converted from Python to internal format)
228    /// * `bindings` - Optional additional variable bindings
229    ///
230    /// # Returns
231    ///
232    /// The result as a JSON string
233    #[pyo3(signature = (data, bindings=None))]
234    fn evaluate_data_to_json(
235        &self,
236        py: Python,
237        data: &JsonataData,
238        bindings: Option<Py<PyAny>>,
239    ) -> PyResult<String> {
240        self.run_eval(py, &data.data, bindings)?
241            .to_json_string()
242            .map_err(|e| PyValueError::new_err(format!("Failed to serialize result: {}", e)))
243    }
244
245    /// Evaluate the expression with JSON string input/output (faster for large data).
246    ///
247    /// This method avoids Python↔Rust conversion overhead by accepting and returning
248    /// JSON strings directly. This is significantly faster for large datasets.
249    ///
250    /// # Arguments
251    ///
252    /// * `json_str` - Input data as a JSON string
253    /// * `bindings` - Optional dict of variable bindings (default: None)
254    ///
255    /// # Returns
256    ///
257    /// The result as a JSON string
258    ///
259    /// # Errors
260    ///
261    /// Returns ValueError if JSON parsing or evaluation fails
262    #[pyo3(signature = (json_str, bindings=None))]
263    fn evaluate_json(
264        &self,
265        py: Python,
266        json_str: &str,
267        bindings: Option<Py<PyAny>>,
268    ) -> PyResult<String> {
269        let json_data = JValue::from_json_str(json_str)
270            .map_err(|e| PyValueError::new_err(format!("Invalid JSON: {}", e)))?;
271        self.run_eval(py, &json_data, bindings)?
272            .to_json_string()
273            .map_err(|e| PyValueError::new_err(format!("Failed to serialize result: {}", e)))
274    }
275}
276
277/// Compile a JSONata expression into an executable form.
278///
279/// # Arguments
280///
281/// * `expression` - A JSONata query/transformation expression string
282///
283/// # Returns
284///
285/// A compiled JsonataExpression that can be evaluated
286///
287/// # Errors
288///
289/// Returns ValueError if the expression cannot be parsed
290///
291/// # Examples
292///
293/// ```python
294/// import jsonatapy
295///
296/// expr = jsonatapy.compile("$.name")
297/// result = expr.evaluate({"name": "Alice"})
298/// print(result)  # "Alice"
299/// ```
300#[cfg(feature = "python")]
301#[pyfunction]
302fn compile(expression: &str) -> PyResult<JsonataExpression> {
303    let ast = parser::parse(expression).map_err(parser_error_to_py)?;
304
305    Ok(JsonataExpression {
306        ast,
307        bytecode: std::cell::OnceCell::new(),
308    })
309}
310
311/// Evaluate a JSONata expression against data in one step.
312///
313/// This is a convenience function that compiles and evaluates in one call.
314/// For repeated evaluations of the same expression, use `compile()` instead.
315///
316/// # Arguments
317///
318/// * `expression` - A JSONata query/transformation expression string
319/// * `data` - A Python object (typically dict) to query/transform
320/// * `bindings` - Optional additional variable bindings
321///
322/// # Returns
323///
324/// The result of evaluating the expression
325///
326/// # Errors
327///
328/// Returns ValueError if parsing or evaluation fails
329///
330/// # Examples
331///
332/// ```python
333/// import jsonatapy
334///
335/// result = jsonatapy.evaluate("$uppercase(name)", {"name": "alice"})
336/// print(result)  # "ALICE"
337/// ```
338#[cfg(feature = "python")]
339#[pyfunction]
340#[pyo3(signature = (expression, data, bindings=None))]
341fn evaluate(
342    py: Python,
343    expression: &str,
344    data: Py<PyAny>,
345    bindings: Option<Py<PyAny>>,
346) -> PyResult<Py<PyAny>> {
347    let expr = compile(expression)?;
348    expr.evaluate(py, data, bindings)
349}
350
351/// Convert a Python object to a JValue.
352///
353/// Handles conversion of Python types:
354/// - None -> Null
355/// - bool -> Bool (checked before int since bool is a subclass of int)
356/// - int, float -> Number
357/// - str -> String
358/// - list -> Array
359/// - dict -> Object
360#[cfg(feature = "python")]
361fn python_to_json(py: Python, obj: &Py<PyAny>) -> PyResult<JValue> {
362    python_to_json_bound(obj.bind(py))
363}
364
365/// Inner conversion using Bound API for zero-overhead type checks.
366///
367/// Uses is_instance_of::<T>() which compiles to C-level type pointer comparisons
368/// (PyBool_Check, PyLong_Check, etc.) — single pointer comparison vs qualname()
369/// which allocates a Python string and does string comparison.
370#[cfg(feature = "python")]
371fn python_to_json_bound(obj: &Bound<'_, PyAny>) -> PyResult<JValue> {
372    if obj.is_none() {
373        return Ok(JValue::Null);
374    }
375
376    // Check bool before int — Python bool is a subclass of int
377    if obj.is_instance_of::<PyBool>() {
378        return Ok(JValue::Bool(obj.extract::<bool>()?));
379    }
380    if obj.is_instance_of::<PyInt>() {
381        return Ok(JValue::Number(obj.extract::<i64>()? as f64));
382    }
383    if obj.is_instance_of::<PyFloat>() {
384        return Ok(JValue::Number(obj.extract::<f64>()?));
385    }
386    if obj.is_instance_of::<PyString>() {
387        return Ok(JValue::string(obj.extract::<String>()?));
388    }
389    if let Ok(list) = obj.cast::<PyList>() {
390        let mut result = Vec::with_capacity(list.len());
391        for item in list.iter() {
392            result.push(python_to_json_bound(&item)?);
393        }
394        return Ok(JValue::array(result));
395    }
396    if let Ok(dict) = obj.cast::<PyDict>() {
397        let mut result = IndexMap::with_capacity(dict.len());
398        for (key, value) in dict.iter() {
399            let key_str = key.extract::<String>()?;
400            result.insert(key_str, python_to_json_bound(&value)?);
401        }
402        return Ok(JValue::object(result));
403    }
404
405    // Fallback for subclasses, numpy types, etc.
406    if let Ok(b) = obj.extract::<bool>() {
407        return Ok(JValue::Bool(b));
408    }
409    if let Ok(i) = obj.extract::<i64>() {
410        return Ok(JValue::Number(i as f64));
411    }
412    if let Ok(f) = obj.extract::<f64>() {
413        return Ok(JValue::Number(f));
414    }
415    if let Ok(s) = obj.extract::<String>() {
416        return Ok(JValue::string(s));
417    }
418
419    Err(PyTypeError::new_err(format!(
420        "Cannot convert Python object to JSON: {}",
421        obj.get_type().name()?
422    )))
423}
424
425/// Convert a JValue to a Python object.
426///
427/// Handles conversion of JValue variants to Python types:
428/// - Null/Undefined -> None
429/// - Bool -> bool
430/// - Number -> int (if whole number) or float
431/// - String -> str
432/// - Array -> list (batch-constructed via PyList::new for fewer C API calls)
433/// - Object -> dict
434/// - Lambda/Builtin/Regex -> None
435#[cfg(feature = "python")]
436fn json_to_python(py: Python, value: &JValue) -> PyResult<Py<PyAny>> {
437    match value {
438        JValue::Null | JValue::Undefined => Ok(py.None()),
439
440        JValue::Bool(b) => Ok(b.into_pyobject(py).unwrap().to_owned().into_any().unbind()),
441
442        JValue::Number(n) => {
443            // If it's a whole number that fits in i64, return as Python int
444            if n.fract() == 0.0 && n.is_finite() && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
445                Ok((*n as i64).into_pyobject(py).unwrap().into_any().unbind())
446            } else {
447                Ok(n.into_pyobject(py).unwrap().into_any().unbind())
448            }
449        }
450
451        JValue::String(s) => Ok((&**s).into_pyobject(py).unwrap().into_any().unbind()),
452
453        JValue::Array(arr) => {
454            // Array of objects with shared keys: intern first object's keys as
455            // Python strings to avoid repeated UTF-8 -> PyString conversion.
456            let all_objects =
457                arr.len() >= 2 && arr.iter().all(|item| matches!(item, JValue::Object(_)));
458            if all_objects {
459                let first_obj = match arr.first() {
460                    Some(JValue::Object(obj)) => obj,
461                    _ => unreachable!("all_objects guard ensures first element is an object"),
462                };
463
464                // Intern keys: store (&str, Py<PyString>) — no String clone needed
465                // since first_obj borrows from arr which outlives this block
466                let interned_keys: Vec<(&str, Py<PyString>)> = first_obj
467                    .keys()
468                    .map(|k| (k.as_str(), PyString::new(py, k).unbind()))
469                    .collect();
470
471                let items: Vec<Py<PyAny>> = arr
472                    .iter()
473                    .map(|item| {
474                        // Safe to unwrap: all_objects guarantees every element is Object
475                        let obj = match item {
476                            JValue::Object(obj) => obj,
477                            _ => unreachable!(),
478                        };
479                        let dict = PyDict::new(py);
480                        for (key_str, py_key) in &interned_keys {
481                            if let Some(value) = obj.get(*key_str) {
482                                dict.set_item(py_key.bind(py), json_to_python(py, value)?)?;
483                            }
484                        }
485                        // Handle any extra keys not in first object
486                        for (key, value) in obj.iter() {
487                            if !first_obj.contains_key(key) {
488                                dict.set_item(key, json_to_python(py, value)?)?;
489                            }
490                        }
491                        Ok(dict.unbind().into())
492                    })
493                    .collect::<PyResult<Vec<_>>>()?;
494                return Ok(PyList::new(py, &items)?.unbind().into());
495            }
496
497            // General array: batch construction
498            let items: Vec<Py<PyAny>> = arr
499                .iter()
500                .map(|item| json_to_python(py, item))
501                .collect::<PyResult<Vec<_>>>()?;
502            Ok(PyList::new(py, &items)?.unbind().into())
503        }
504
505        JValue::Object(obj) => {
506            let dict = PyDict::new(py);
507            for (key, value) in obj.iter() {
508                dict.set_item(key, json_to_python(py, value)?)?;
509            }
510            Ok(dict.unbind().into())
511        }
512
513        JValue::Lambda { .. } | JValue::Builtin { .. } | JValue::Regex { .. } => Ok(py.None()),
514    }
515}
516
517/// Create an evaluator, optionally configured with Python bindings
518#[cfg(feature = "python")]
519fn create_evaluator(py: Python, bindings: Option<Py<PyAny>>) -> PyResult<evaluator::Evaluator> {
520    if let Some(bindings_obj) = bindings {
521        let bindings_json = python_to_json(py, &bindings_obj)?;
522
523        let mut context = evaluator::Context::new();
524        if let JValue::Object(map) = bindings_json {
525            for (key, value) in map.iter() {
526                context.bind(key.clone(), value.clone());
527            }
528        } else {
529            return Err(PyTypeError::new_err("bindings must be a dictionary"));
530        }
531        Ok(evaluator::Evaluator::with_context(context))
532    } else {
533        Ok(evaluator::Evaluator::new())
534    }
535}
536
537/// Convert an EvaluatorError to a PyErr
538#[cfg(feature = "python")]
539fn evaluator_error_to_py(e: evaluator::EvaluatorError) -> PyErr {
540    match e {
541        evaluator::EvaluatorError::TypeError(msg) => PyValueError::new_err(msg),
542        evaluator::EvaluatorError::ReferenceError(msg) => PyValueError::new_err(msg),
543        evaluator::EvaluatorError::EvaluationError(msg) => PyValueError::new_err(msg),
544    }
545}
546
547/// Format a ParserError's Python-facing message.
548/// Coded errors (e.g., S0214) have their message formatted as "code: message",
549/// so they are passed through directly without an additional "Parse error: " prefix.
550/// Other errors get the "Parse error: " prefix for clarity.
551///
552/// Split out from `parser_error_to_py` so this formatting logic can be unit
553/// tested without constructing a `PyErr` (which requires an initialized
554/// Python interpreter -- fine under `maturin develop`/pytest, but panics
555/// under a plain `cargo test --all-features` with no embedded interpreter).
556fn format_parser_error_message(e: &parser::ParserError) -> String {
557    let msg = e.to_string();
558    if matches!(e, parser::ParserError::Coded { .. }) {
559        // Coded errors already have the format "code: message"
560        msg
561    } else {
562        // Other errors get the "Parse error: " prefix
563        format!("Parse error: {}", msg)
564    }
565}
566
567/// Convert a ParserError to a PyErr
568#[cfg(feature = "python")]
569fn parser_error_to_py(e: parser::ParserError) -> PyErr {
570    PyValueError::new_err(format_parser_error_message(&e))
571}
572
573/// JSONata Python module
574#[cfg(feature = "python")]
575#[pymodule]
576fn _jsonatapy(m: &Bound<'_, PyModule>) -> PyResult<()> {
577    m.add_function(wrap_pyfunction!(compile, m)?)?;
578    m.add_function(wrap_pyfunction!(evaluate, m)?)?;
579    m.add_class::<JsonataExpression>()?;
580    m.add_class::<JsonataData>()?;
581
582    // Add version info
583    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
584    m.add("__jsonata_version__", JSONATA_REFERENCE_VERSION)?;
585
586    Ok(())
587}
588
589#[cfg(test)]
590mod tests {
591    #[test]
592    fn test_module_creation() {
593        // Basic smoke test
594        assert!(!env!("CARGO_PKG_VERSION").is_empty());
595    }
596
597    mod parser_error_handling {
598        use super::super::*;
599
600        // These test format_parser_error_message() directly (a plain string
601        // function) rather than parser_error_to_py(), which constructs a
602        // PyErr -- that requires an initialized Python interpreter, which
603        // isn't available under a bare `cargo test --all-features` run (no
604        // embedded interpreter, unlike the maturin-built extension loaded
605        // into a live Python process). The formatting logic under test is
606        // identical either way.
607
608        #[test]
609        fn test_parser_error_to_py_coded_error_no_prefix() {
610            // Test that coded errors (like S0214) are passed through without "Parse error: " prefix
611            let coded_error = parser::ParserError::Coded {
612                code: "S0214",
613                message: "Expected a variable reference after @".to_string(),
614            };
615            let msg = format_parser_error_message(&coded_error);
616
617            // The message should start with the code, not "Parse error: "
618            assert!(
619                msg.starts_with("S0214:"),
620                "Expected message to start with 'S0214:', got: {}",
621                msg
622            );
623            assert!(
624                !msg.starts_with("Parse error:"),
625                "Expected no 'Parse error:' prefix, got: {}",
626                msg
627            );
628        }
629
630        #[test]
631        fn test_parser_error_to_py_non_coded_error_with_prefix() {
632            // Test that non-coded errors still get the "Parse error: " prefix
633            let non_coded_error = parser::ParserError::UnexpectedToken("foo".to_string());
634            let msg = format_parser_error_message(&non_coded_error);
635
636            // The message should have the "Parse error: " prefix
637            assert!(
638                msg.starts_with("Parse error:"),
639                "Expected message to start with 'Parse error:', got: {}",
640                msg
641            );
642        }
643    }
644}