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;
38#[cfg(feature = "python")]
39pub mod lazy;
40pub mod parser;
41mod signature;
42pub mod value;
43mod vm;
44
45// ── Benchmarking facade (only when the "bench" feature is enabled) ────────────
46//
47// Exposes the compiler/VM pipeline for Criterion benchmarks without making
48// the internals part of the permanent public API.
49
50/// Internal benchmarking API — do not use in production code.
51///
52/// Enabled with `--features bench`. Provides access to the bytecode compiler
53/// and VM so that Criterion benchmarks can measure tree-walker vs VM directly.
54#[cfg(feature = "bench")]
55pub mod _bench {
56    use crate::ast::AstNode;
57    pub use crate::evaluator::EvaluatorError;
58    use crate::value::JValue;
59
60    /// An opaque handle to a compiled bytecode program.
61    pub struct CompiledProgram(crate::vm::BytecodeProgram);
62
63    /// Compile an AST node to bytecode.
64    ///
65    /// Returns `None` if the expression contains constructs the compiler
66    /// doesn't handle (e.g. wildcards, `$eval`, higher-order functions at
67    /// the top level). In that case, fall back to `Evaluator::new().evaluate()`.
68    pub fn compile(ast: &AstNode) -> Option<CompiledProgram> {
69        crate::evaluator::try_compile_expr(ast)
70            .map(|ce| CompiledProgram(crate::compiler::BytecodeCompiler::compile(&ce)))
71    }
72
73    /// Execute a compiled program against `data`.
74    pub fn run(prog: &CompiledProgram, data: &JValue) -> Result<JValue, EvaluatorError> {
75        crate::vm::Vm::with_options(&prog.0, crate::evaluator::EvaluatorOptions::default())
76            .run(data, None)
77    }
78}
79
80// ── Python bindings (only when the "python" feature is enabled) ───────────────
81
82/// The JSONata reference implementation version this library targets.
83const JSONATA_REFERENCE_VERSION: &str = "2.1.0";
84
85#[cfg(feature = "python")]
86use crate::value::JValue;
87#[cfg(feature = "python")]
88use pyo3::exceptions::{PyTypeError, PyValueError};
89#[cfg(feature = "python")]
90use pyo3::prelude::*;
91#[cfg(feature = "python")]
92use pyo3::types::{PyDict, PyList, PyString};
93
94/// Pre-converted data handle for efficient repeated evaluation.
95///
96/// Convert Python data to an internal representation once, then reuse it
97/// across multiple evaluations to avoid repeated Python↔Rust conversion overhead.
98///
99/// # Examples
100///
101/// ```python
102/// import jsonatapy
103///
104/// data = jsonatapy.JsonataData({"orders": [{"price": 150}, {"price": 50}]})
105/// expr = jsonatapy.compile("orders[price > 100]")
106/// result = expr.evaluate_with_data(data)
107/// ```
108#[cfg(feature = "python")]
109#[pyclass(unsendable)]
110struct JsonataData {
111    data: JValue,
112}
113
114#[cfg(feature = "python")]
115#[pymethods]
116impl JsonataData {
117    /// Create from a Python object (dict, list, etc.)
118    #[new]
119    fn new(py: Python, data: Py<PyAny>) -> PyResult<Self> {
120        let jvalue = python_to_json(py, &data)?;
121        Ok(JsonataData { data: jvalue })
122    }
123
124    /// Create from a JSON string (fastest path).
125    #[staticmethod]
126    fn from_json(json_str: &str) -> PyResult<Self> {
127        let data = JValue::from_json_str(json_str)
128            .map_err(|e| PyValueError::new_err(format!("Invalid JSON: {}", e)))?;
129        Ok(JsonataData { data })
130    }
131}
132
133/// A compiled JSONata expression that can be evaluated against data.
134///
135/// This is the main entry point for using JSONata. Compile an expression once,
136/// then evaluate it multiple times against different data.
137///
138/// # Examples
139///
140/// ```python
141/// import jsonatapy
142///
143/// # Compile once
144/// expr = jsonatapy.compile("orders[price > 100].product")
145///
146/// # Evaluate many times
147/// data1 = {"orders": [{"product": "A", "price": 150}]}
148/// result1 = expr.evaluate(data1)
149///
150/// data2 = {"orders": [{"product": "B", "price": 50}]}
151/// result2 = expr.evaluate(data2)
152/// ```
153#[cfg(feature = "python")]
154#[pyclass(unsendable)]
155struct JsonataExpression {
156    /// The parsed Abstract Syntax Tree
157    ast: ast::AstNode,
158    /// Lazily compiled bytecode — populated on first evaluate() call.
159    /// `Some(bc)` = fast VM path; `None` = must use tree-walker.
160    /// `OnceCell` ensures compilation happens at most once per expression instance.
161    bytecode: std::cell::OnceCell<Option<vm::BytecodeProgram>>,
162    /// Default guardrail options set at `compile()` time. Per-call `evaluate*()`
163    /// kwargs override these on a field-by-field basis (see the `.or(...)` merges
164    /// in the `#[pymethods]` impl below).
165    default_options: evaluator::EvaluatorOptions,
166}
167
168/// Test-support toggle: set JSONATAPY_FORCE_TREE_WALKER=1 to bypass the
169/// bytecode VM and exercise the tree-walking evaluator on every call.
170/// Read per-call (not cached) so tests can flip it via monkeypatch.setenv;
171/// the ~100ns env read is noise next to a µs-scale evaluation.
172#[cfg(feature = "python")]
173fn force_tree_walker() -> bool {
174    std::env::var_os("JSONATAPY_FORCE_TREE_WALKER").is_some_and(|v| !v.is_empty() && v != "0")
175}
176
177#[cfg(feature = "python")]
178impl JsonataExpression {
179    /// Evaluate the compiled expression against pre-converted data.
180    /// Uses bytecode VM when available, falls back to tree-walker.
181    fn run_eval(
182        &self,
183        py: Python,
184        data: &JValue,
185        bindings: Option<Py<PyAny>>,
186        options: evaluator::EvaluatorOptions,
187    ) -> PyResult<JValue> {
188        if bindings.is_none() && !force_tree_walker() {
189            let bytecode = self.bytecode.get_or_init(|| {
190                evaluator::try_compile_expr(&self.ast)
191                    .map(|ce| compiler::BytecodeCompiler::compile(&ce))
192            });
193            if let Some(bc) = bytecode {
194                vm::Vm::with_options(bc, options.clone())
195                    .run(data, None)
196                    .map_err(evaluator_error_to_py)
197            } else {
198                let mut ev = evaluator::Evaluator::with_options(evaluator::Context::new(), options);
199                ev.evaluate(&self.ast, data).map_err(evaluator_error_to_py)
200            }
201        } else {
202            let mut ev = create_evaluator(py, bindings, options)?;
203            ev.evaluate(&self.ast, data).map_err(evaluator_error_to_py)
204        }
205    }
206}
207
208#[cfg(feature = "python")]
209#[pymethods]
210impl JsonataExpression {
211    /// Returns ValueError if evaluation fails
212    #[pyo3(signature = (data, bindings=None, timeout=None, max_stack_depth=None, max_sequence_length=None))]
213    #[allow(clippy::too_many_arguments)]
214    fn evaluate(
215        &self,
216        py: Python,
217        data: Py<PyAny>,
218        bindings: Option<Py<PyAny>>,
219        timeout: Option<u64>,
220        max_stack_depth: Option<usize>,
221        max_sequence_length: Option<usize>,
222    ) -> PyResult<Py<PyAny>> {
223        let json_data = lazy::convert(data.bind(py), true)?;
224        let options = evaluator::EvaluatorOptions {
225            timeout_ms: timeout.or(self.default_options.timeout_ms),
226            max_stack_depth: max_stack_depth.or(self.default_options.max_stack_depth),
227            max_sequence_length: max_sequence_length.or(self.default_options.max_sequence_length),
228        };
229        json_to_python(py, &self.run_eval(py, &json_data, bindings, options)?)
230    }
231
232    /// Evaluate with a pre-converted data handle (fastest for repeated evaluation).
233    ///
234    /// # Arguments
235    ///
236    /// * `data` - A JsonataData handle (pre-converted from Python to internal format)
237    /// * `bindings` - Optional additional variable bindings
238    ///
239    /// # Returns
240    ///
241    /// The result of evaluating the expression
242    #[pyo3(signature = (data, bindings=None, timeout=None, max_stack_depth=None, max_sequence_length=None))]
243    #[allow(clippy::too_many_arguments)]
244    fn evaluate_with_data(
245        &self,
246        py: Python,
247        data: &JsonataData,
248        bindings: Option<Py<PyAny>>,
249        timeout: Option<u64>,
250        max_stack_depth: Option<usize>,
251        max_sequence_length: Option<usize>,
252    ) -> PyResult<Py<PyAny>> {
253        let options = evaluator::EvaluatorOptions {
254            timeout_ms: timeout.or(self.default_options.timeout_ms),
255            max_stack_depth: max_stack_depth.or(self.default_options.max_stack_depth),
256            max_sequence_length: max_sequence_length.or(self.default_options.max_sequence_length),
257        };
258        json_to_python(py, &self.run_eval(py, &data.data, bindings, options)?)
259    }
260
261    /// Evaluate with a pre-converted data handle, return JSON string (zero-overhead output).
262    ///
263    /// # Arguments
264    ///
265    /// * `data` - A JsonataData handle (pre-converted from Python to internal format)
266    /// * `bindings` - Optional additional variable bindings
267    ///
268    /// # Returns
269    ///
270    /// The result as a JSON string
271    #[pyo3(signature = (data, bindings=None, timeout=None, max_stack_depth=None, max_sequence_length=None))]
272    #[allow(clippy::too_many_arguments)]
273    fn evaluate_data_to_json(
274        &self,
275        py: Python,
276        data: &JsonataData,
277        bindings: Option<Py<PyAny>>,
278        timeout: Option<u64>,
279        max_stack_depth: Option<usize>,
280        max_sequence_length: Option<usize>,
281    ) -> PyResult<String> {
282        let options = evaluator::EvaluatorOptions {
283            timeout_ms: timeout.or(self.default_options.timeout_ms),
284            max_stack_depth: max_stack_depth.or(self.default_options.max_stack_depth),
285            max_sequence_length: max_sequence_length.or(self.default_options.max_sequence_length),
286        };
287        self.run_eval(py, &data.data, bindings, options)?
288            .to_json_string()
289            .map_err(|e| PyValueError::new_err(format!("Failed to serialize result: {}", e)))
290    }
291
292    /// Evaluate the expression with JSON string input/output (faster for large data).
293    ///
294    /// This method avoids Python↔Rust conversion overhead by accepting and returning
295    /// JSON strings directly. This is significantly faster for large datasets.
296    ///
297    /// # Arguments
298    ///
299    /// * `json_str` - Input data as a JSON string
300    /// * `bindings` - Optional dict of variable bindings (default: None)
301    ///
302    /// # Returns
303    ///
304    /// The result as a JSON string
305    ///
306    /// # Errors
307    ///
308    /// Returns ValueError if JSON parsing or evaluation fails
309    #[pyo3(signature = (json_str, bindings=None, timeout=None, max_stack_depth=None, max_sequence_length=None))]
310    #[allow(clippy::too_many_arguments)]
311    fn evaluate_json(
312        &self,
313        py: Python,
314        json_str: &str,
315        bindings: Option<Py<PyAny>>,
316        timeout: Option<u64>,
317        max_stack_depth: Option<usize>,
318        max_sequence_length: Option<usize>,
319    ) -> PyResult<String> {
320        let json_data = JValue::from_json_str(json_str)
321            .map_err(|e| PyValueError::new_err(format!("Invalid JSON: {}", e)))?;
322        let options = evaluator::EvaluatorOptions {
323            timeout_ms: timeout.or(self.default_options.timeout_ms),
324            max_stack_depth: max_stack_depth.or(self.default_options.max_stack_depth),
325            max_sequence_length: max_sequence_length.or(self.default_options.max_sequence_length),
326        };
327        self.run_eval(py, &json_data, bindings, options)?
328            .to_json_string()
329            .map_err(|e| PyValueError::new_err(format!("Failed to serialize result: {}", e)))
330    }
331
332    /// Evaluate with JSON string input, distinguishing an Undefined result
333    /// (returns Python None) from an explicit JSON null result (returns
334    /// the string "null"). evaluate_json() cannot make this distinction --
335    /// JSON serialization has no way to represent "undefined" separately
336    /// from "null" -- so this method checks the raw evaluated JValue's
337    /// is_undefined() BEFORE serializing, exposing the same signal the
338    /// Rust CLI (src/bin/jsonata/main.rs) already uses internally.
339    ///
340    /// `json_str=None` means no input document at all -- the top-level
341    /// context (`$`) is bound to a true `Undefined`, matching the Rust
342    /// CLI's `--null-input` behavior. This is distinct from passing the
343    /// text `"null"`, which binds `$` to an explicit JSON null.
344    ///
345    /// # Errors
346    ///
347    /// Returns ValueError if JSON parsing or evaluation fails
348    #[pyo3(signature = (json_str, bindings=None, timeout=None, max_stack_depth=None, max_sequence_length=None))]
349    #[allow(clippy::too_many_arguments)]
350    fn evaluate_json_or_none(
351        &self,
352        py: Python,
353        json_str: Option<&str>,
354        bindings: Option<Py<PyAny>>,
355        timeout: Option<u64>,
356        max_stack_depth: Option<usize>,
357        max_sequence_length: Option<usize>,
358    ) -> PyResult<Option<String>> {
359        let json_data = match json_str {
360            Some(s) => JValue::from_json_str(s)
361                .map_err(|e| PyValueError::new_err(format!("Invalid JSON: {}", e)))?,
362            None => JValue::Undefined,
363        };
364        let options = evaluator::EvaluatorOptions {
365            timeout_ms: timeout.or(self.default_options.timeout_ms),
366            max_stack_depth: max_stack_depth.or(self.default_options.max_stack_depth),
367            max_sequence_length: max_sequence_length.or(self.default_options.max_sequence_length),
368        };
369        let result = self.run_eval(py, &json_data, bindings, options)?;
370        if result.is_undefined() {
371            return Ok(None);
372        }
373        result
374            .to_json_string()
375            .map(Some)
376            .map_err(|e| PyValueError::new_err(format!("Failed to serialize result: {}", e)))
377    }
378}
379
380/// Compile a JSONata expression into an executable form.
381///
382/// # Arguments
383///
384/// * `expression` - A JSONata query/transformation expression string
385///
386/// # Returns
387///
388/// A compiled JsonataExpression that can be evaluated
389///
390/// # Errors
391///
392/// Returns ValueError if the expression cannot be parsed
393///
394/// # Examples
395///
396/// ```python
397/// import jsonatapy
398///
399/// expr = jsonatapy.compile("$.name")
400/// result = expr.evaluate({"name": "Alice"})
401/// print(result)  # "Alice"
402/// ```
403#[cfg(feature = "python")]
404#[pyfunction]
405#[pyo3(signature = (expression, timeout=None, max_stack_depth=None, max_sequence_length=None))]
406fn compile(
407    expression: &str,
408    timeout: Option<u64>,
409    max_stack_depth: Option<usize>,
410    max_sequence_length: Option<usize>,
411) -> PyResult<JsonataExpression> {
412    let ast = parser::parse(expression).map_err(parser_error_to_py)?;
413
414    Ok(JsonataExpression {
415        ast,
416        bytecode: std::cell::OnceCell::new(),
417        default_options: build_evaluator_options(timeout, max_stack_depth, max_sequence_length),
418    })
419}
420
421/// Evaluate a JSONata expression against data in one step.
422///
423/// This is a convenience function that compiles and evaluates in one call.
424/// For repeated evaluations of the same expression, use `compile()` instead.
425///
426/// # Arguments
427///
428/// * `expression` - A JSONata query/transformation expression string
429/// * `data` - A Python object (typically dict) to query/transform
430/// * `bindings` - Optional additional variable bindings
431///
432/// # Returns
433///
434/// The result of evaluating the expression
435///
436/// # Errors
437///
438/// Returns ValueError if parsing or evaluation fails
439///
440/// # Examples
441///
442/// ```python
443/// import jsonatapy
444///
445/// result = jsonatapy.evaluate("$uppercase(name)", {"name": "alice"})
446/// print(result)  # "ALICE"
447/// ```
448#[cfg(feature = "python")]
449#[pyfunction]
450#[pyo3(signature = (expression, data, bindings=None, timeout=None, max_stack_depth=None, max_sequence_length=None))]
451#[allow(clippy::too_many_arguments)]
452fn evaluate(
453    py: Python,
454    expression: &str,
455    data: Py<PyAny>,
456    bindings: Option<Py<PyAny>>,
457    timeout: Option<u64>,
458    max_stack_depth: Option<usize>,
459    max_sequence_length: Option<usize>,
460) -> PyResult<Py<PyAny>> {
461    let expr = compile(expression, None, None, None)?;
462    expr.evaluate(
463        py,
464        data,
465        bindings,
466        timeout,
467        max_stack_depth,
468        max_sequence_length,
469    )
470}
471
472/// Convert a Python object to a JValue.
473///
474/// Handles conversion of Python types:
475/// - None -> Null
476/// - bool -> Bool (checked before int since bool is a subclass of int)
477/// - int, float -> Number
478/// - str -> String
479/// - list -> Array
480/// - dict -> Object
481#[cfg(feature = "python")]
482fn python_to_json(py: Python, obj: &Py<PyAny>) -> PyResult<JValue> {
483    python_to_json_bound(obj.bind(py))
484}
485
486/// Inner conversion using the Bound API. Delegates to `lazy::convert` with
487/// `lazy=false` for today's eager, fully-materialized conversion (see
488/// `src/lazy.rs` for the zero-overhead type-check details and the lazy path).
489#[cfg(feature = "python")]
490fn python_to_json_bound(obj: &Bound<'_, PyAny>) -> PyResult<JValue> {
491    lazy::convert(obj, false)
492}
493
494/// Convert a JValue to a Python object.
495///
496/// Handles conversion of JValue variants to Python types:
497/// - Null/Undefined -> None
498/// - Bool -> bool
499/// - Number -> int (if whole number) or float
500/// - String -> str
501/// - Array -> list (batch-constructed via PyList::new for fewer C API calls)
502/// - Object -> dict
503/// - Lambda/Builtin/Regex -> None
504#[cfg(feature = "python")]
505fn json_to_python(py: Python, value: &JValue) -> PyResult<Py<PyAny>> {
506    match value {
507        JValue::Null | JValue::Undefined => Ok(py.None()),
508
509        JValue::Bool(b) => Ok(b.into_pyobject(py).unwrap().to_owned().into_any().unbind()),
510
511        JValue::Number(n) => {
512            // If it's a whole number that fits in i64, return as Python int
513            if n.fract() == 0.0 && n.is_finite() && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
514                Ok((*n as i64).into_pyobject(py).unwrap().into_any().unbind())
515            } else {
516                Ok(n.into_pyobject(py).unwrap().into_any().unbind())
517            }
518        }
519
520        JValue::String(s) => Ok((&**s).into_pyobject(py).unwrap().into_any().unbind()),
521
522        JValue::Array(arr) => {
523            // Array of objects with shared keys: intern first object's keys as
524            // Python strings to avoid repeated UTF-8 -> PyString conversion.
525            let all_objects =
526                arr.len() >= 2 && arr.iter().all(|item| matches!(item, JValue::Object(_)));
527            if all_objects {
528                let first_obj = match arr.first() {
529                    Some(JValue::Object(obj)) => obj,
530                    _ => unreachable!("all_objects guard ensures first element is an object"),
531                };
532
533                // Intern keys: store (&str, Py<PyString>) — no String clone needed
534                // since first_obj borrows from arr which outlives this block
535                let interned_keys: Vec<(&str, Py<PyString>)> = first_obj
536                    .keys()
537                    .map(|k| (k.as_str(), PyString::new(py, k).unbind()))
538                    .collect();
539
540                let items: Vec<Py<PyAny>> = arr
541                    .iter()
542                    .map(|item| {
543                        // Safe to unwrap: all_objects guarantees every element is Object
544                        let obj = match item {
545                            JValue::Object(obj) => obj,
546                            _ => unreachable!(),
547                        };
548                        let dict = PyDict::new(py);
549                        for (key_str, py_key) in &interned_keys {
550                            if let Some(value) = obj.get(*key_str) {
551                                dict.set_item(py_key.bind(py), json_to_python(py, value)?)?;
552                            }
553                        }
554                        // Handle any extra keys not in first object
555                        for (key, value) in obj.iter() {
556                            if !first_obj.contains_key(key) {
557                                dict.set_item(key, json_to_python(py, value)?)?;
558                            }
559                        }
560                        Ok(dict.unbind().into())
561                    })
562                    .collect::<PyResult<Vec<_>>>()?;
563                return Ok(PyList::new(py, &items)?.unbind().into());
564            }
565
566            // General array: batch construction
567            let items: Vec<Py<PyAny>> = arr
568                .iter()
569                .map(|item| json_to_python(py, item))
570                .collect::<PyResult<Vec<_>>>()?;
571            Ok(PyList::new(py, &items)?.unbind().into())
572        }
573
574        JValue::Object(obj) => {
575            let dict = PyDict::new(py);
576            for (key, value) in obj.iter() {
577                dict.set_item(key, json_to_python(py, value)?)?;
578            }
579            Ok(dict.unbind().into())
580        }
581
582        JValue::Lambda { .. } | JValue::Builtin { .. } | JValue::Regex { .. } => Ok(py.None()),
583
584        JValue::LazyPyDict(lazy) => Ok(lazy.py_object().clone_ref(py).into_any()),
585    }
586}
587
588/// Build an `EvaluatorOptions` from the Python-facing `timeout`/`max_stack_depth`/
589/// `max_sequence_length` kwargs used by `compile()` and the free-standing `evaluate()`.
590#[cfg(feature = "python")]
591fn build_evaluator_options(
592    timeout: Option<u64>,
593    max_stack_depth: Option<usize>,
594    max_sequence_length: Option<usize>,
595) -> evaluator::EvaluatorOptions {
596    evaluator::EvaluatorOptions {
597        timeout_ms: timeout,
598        max_stack_depth,
599        max_sequence_length,
600    }
601}
602
603/// Create an evaluator, optionally configured with Python bindings
604#[cfg(feature = "python")]
605fn create_evaluator(
606    py: Python,
607    bindings: Option<Py<PyAny>>,
608    options: evaluator::EvaluatorOptions,
609) -> PyResult<evaluator::Evaluator> {
610    let mut context = evaluator::Context::new();
611    if let Some(bindings_obj) = bindings {
612        let bindings_json = python_to_json(py, &bindings_obj)?;
613        if let JValue::Object(map) = bindings_json {
614            for (key, value) in map.iter() {
615                context.bind(key.clone(), value.clone());
616            }
617        } else {
618            return Err(PyTypeError::new_err("bindings must be a dictionary"));
619        }
620    }
621    Ok(evaluator::Evaluator::with_options(context, options))
622}
623
624/// Convert an EvaluatorError to a PyErr
625#[cfg(feature = "python")]
626fn evaluator_error_to_py(e: evaluator::EvaluatorError) -> PyErr {
627    match e {
628        evaluator::EvaluatorError::PyConversionError(m) => PyTypeError::new_err(m),
629        other => PyValueError::new_err(other.message().to_string()),
630    }
631}
632
633/// Convert a ParserError to a PyErr
634#[cfg(feature = "python")]
635fn parser_error_to_py(e: parser::ParserError) -> PyErr {
636    PyValueError::new_err(e.display_message())
637}
638
639/// JSONata Python module
640#[cfg(feature = "python")]
641#[pymodule]
642fn _jsonatapy(m: &Bound<'_, PyModule>) -> PyResult<()> {
643    m.add_function(wrap_pyfunction!(compile, m)?)?;
644    m.add_function(wrap_pyfunction!(evaluate, m)?)?;
645    m.add_class::<JsonataExpression>()?;
646    m.add_class::<JsonataData>()?;
647
648    // Add version info
649    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
650    m.add("__jsonata_version__", JSONATA_REFERENCE_VERSION)?;
651
652    Ok(())
653}
654
655#[cfg(test)]
656mod tests {
657    #[test]
658    fn test_module_creation() {
659        // Basic smoke test
660        assert!(!env!("CARGO_PKG_VERSION").is_empty());
661    }
662
663    mod parser_error_handling {
664        use super::super::*;
665
666        // These test ParserError::display_message() directly (a plain string
667        // method) rather than parser_error_to_py(), which constructs a
668        // PyErr -- that requires an initialized Python interpreter, which
669        // isn't available under a bare `cargo test --all-features` run (no
670        // embedded interpreter, unlike the maturin-built extension loaded
671        // into a live Python process). The formatting logic under test is
672        // identical either way.
673
674        #[test]
675        fn test_parser_error_to_py_coded_error_no_prefix() {
676            // Test that coded errors (like S0214) are passed through without "Parse error: " prefix
677            let coded_error = parser::ParserError::Coded {
678                code: "S0214",
679                message: "Expected a variable reference after @".to_string(),
680            };
681            let msg = coded_error.display_message();
682
683            // The message should start with the code, not "Parse error: "
684            assert!(
685                msg.starts_with("S0214:"),
686                "Expected message to start with 'S0214:', got: {}",
687                msg
688            );
689            assert!(
690                !msg.starts_with("Parse error:"),
691                "Expected no 'Parse error:' prefix, got: {}",
692                msg
693            );
694        }
695
696        #[test]
697        fn test_parser_error_to_py_non_coded_error_with_prefix() {
698            // Test that non-coded errors still get the "Parse error: " prefix
699            let non_coded_error = parser::ParserError::UnexpectedToken("foo".to_string());
700            let msg = non_coded_error.display_message();
701
702            // The message should have the "Parse error: " prefix
703            assert!(
704                msg.starts_with("Parse error:"),
705                "Expected message to start with 'Parse error:', got: {}",
706                msg
707            );
708        }
709    }
710}