jsonatapy 2.2.12__tar.gz → 2.2.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/CHANGELOG.md +49 -0
  2. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/Cargo.lock +1 -1
  3. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/Cargo.toml +1 -1
  4. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/PKG-INFO +7 -7
  5. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/README.md +4 -4
  6. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/pyproject.toml +4 -4
  7. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/capi.rs +1 -0
  8. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/evaluator.rs +99 -6
  9. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/lib.rs +2 -0
  10. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/vm.rs +20 -0
  11. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/integration_test.rs +156 -1
  12. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.gitignore +0 -0
  13. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.gitmodules +0 -0
  14. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/.gitignore +0 -0
  15. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/architecture.md +0 -0
  16. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/conventions.md +0 -0
  17. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/core.md +0 -0
  18. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/memory_maintenance.md +0 -0
  19. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/suggested_commands.md +0 -0
  20. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/task_completion.md +0 -0
  21. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/.serena/memories/tech_stack.md +0 -0
  22. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/CONTRIBUTORS.md +0 -0
  23. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/LICENSE +0 -0
  24. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/SECURITY.md +0 -0
  25. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/benches/evaluator_bench.rs +0 -0
  26. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/bindings/c/README.md +0 -0
  27. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/bindings/c/examples/smoke.c +0 -0
  28. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/bindings/c/jsonata.h +0 -0
  29. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/examples/evaluator_demo.rs +0 -0
  30. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/examples/host_functions.rs +0 -0
  31. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/examples/parser_demo.rs +0 -0
  32. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/examples/simd_json_bench.rs +0 -0
  33. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/__init__.py +0 -0
  34. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/__main__.py +0 -0
  35. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/_cli/__init__.py +0 -0
  36. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/_cli/bindings.py +0 -0
  37. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/_cli/error_format.py +0 -0
  38. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/_cli/mcp_server.py +0 -0
  39. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/_cli/resolve.py +0 -0
  40. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/_cli/run.py +0 -0
  41. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/python/jsonatapy/py.typed +0 -0
  42. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/allocator.rs +0 -0
  43. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/ast.rs +0 -0
  44. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/ast_transform.rs +0 -0
  45. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/bin/jsonata/bindings.rs +0 -0
  46. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/bin/jsonata/error_format.rs +0 -0
  47. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/bin/jsonata/main.rs +0 -0
  48. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/bin/jsonata/resolve.rs +0 -0
  49. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/builtins.rs +0 -0
  50. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/compiler.rs +0 -0
  51. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/datetime.rs +0 -0
  52. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/expression.rs +0 -0
  53. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/functions.rs +0 -0
  54. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/lazy.rs +0 -0
  55. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/parser.rs +0 -0
  56. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/pystack.rs +0 -0
  57. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/signature.rs +0 -0
  58. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/src/value.rs +0 -0
  59. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/study/cli_fixtures.json +0 -0
  60. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/study/cli_fixtures_testdata.json +0 -0
  61. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/study/cli_spec.md +0 -0
  62. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/array_flags_test.rs +0 -0
  63. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/cli_fixtures_test.rs +0 -0
  64. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/cli_test.rs +0 -0
  65. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/common/mod.rs +0 -0
  66. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/datetime_picture_suite.rs +0 -0
  67. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/deep_value_drop_test.rs +0 -0
  68. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/evaluator_reuse_test.rs +0 -0
  69. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/array_flags_differential.json +0 -0
  70. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/builtin_arity.json +0 -0
  71. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/builtin_differential.json +0 -0
  72. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/builtin_signatures.json +0 -0
  73. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/fastpath_differential.json +0 -0
  74. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/fastpath_known_divergences.json +0 -0
  75. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/path_semantics_combinations.json +0 -0
  76. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/path_semantics_combinations_known.json +0 -0
  77. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/fixtures/path_semantics_differential.json +0 -0
  78. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/host_functions_test.rs +0 -0
  79. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/lambda_closure_suite.rs +0 -0
  80. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/parent_and_focus_binding_suite.rs +0 -0
  81. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/path_semantics_test.rs +0 -0
  82. {jsonatapy-2.2.12 → jsonatapy-2.2.13}/tests/step_cache_test.rs +0 -0
@@ -19,6 +19,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
19
 
20
20
  ### Security
21
21
 
22
+ ## [2.2.13] - 2026-10-24
23
+
24
+ **Upgrade notes**
25
+
26
+ - **Breaking for Rust code that builds `EvaluatorOptions` field by field.** `EvaluatorOptions`
27
+ has a new public `cancel` field (see Added), so a struct literal that lists every field
28
+ without `..Default::default()` no longer compiles. Add `cancel: None`, or end the literal with
29
+ `..Default::default()`. Strict semver would call this a major change. It ships in a patch
30
+ release because jsonata-core's version tracks the jsonata-js version it targets (2.2), and
31
+ code that uses `..Default::default()` is unaffected. The Python and C APIs are unchanged.
32
+ - **`jsonatapy[mcp]` now needs fastmcp 4.1.2 or later**, which brings `mcp` 2.x (see Changed).
33
+
34
+ ### Added
35
+
36
+ - **An evaluation can be cancelled from another thread.** `EvaluatorOptions` has a new
37
+ `cancel` field that takes a `CancelToken`. Calling `cancel()` on any clone of the token
38
+ stops the evaluation with `U1003: Evaluation cancelled` wherever `timeout_ms` is checked:
39
+ each node evaluated, each element a higher-order builtin hands its callback, each match
40
+ `$replace` hands its replacement function, and each pass of a tail-call loop. A single
41
+ builtin call over a large input finishes first, as it does under `timeout_ms`, and if it is
42
+ the last step the evaluation returns its result. jsonata-js has no cancellation, so the
43
+ code is in the `U` series. This is a Rust API only. See the upgrade notes above for the
44
+ `EvaluatorOptions` source break. (#264) Thanks to @jonking-ajar for the design, the
45
+ implementation and its tests.
46
+
47
+ ### Changed
48
+
49
+ - **The `mcp` extra now needs fastmcp 4.1.2 or later** (was 3.0). fastmcp 4 brings `mcp` 2.x
50
+ and replaces `httpx` with `httpx2`, so an environment that pins `mcp<2` or depends on
51
+ `httpx` alongside `jsonatapy[mcp]` may need updating. The `jsonatapy mcp` server itself is
52
+ unchanged. 4.1.2 is also the first 4.x release with the fix for GHSA-7xgj-8392-5q4r.
53
+
54
+ ### Deprecated
55
+
56
+ ### Removed
57
+
58
+ ### Fixed
59
+
60
+ - **`timeout_ms` now stops `$map`, `$filter`, `$reduce`, `$single`, `$sift`, `$each` and
61
+ `$sort` when their callback is a builtin, host or partial function, and `$replace` when its
62
+ replacement function is one.** Their loops over such a function had no checkpoint, because
63
+ the call evaluates no node, so `$map(items, $string)` over a large array ran to the end past
64
+ its timeout. A lambda callback was already stopped, since its body is checked like any node.
65
+ An expression with `timeout_ms` set that used to finish late in one of these loops and
66
+ return a result now fails with D1012 instead. (#264) Thanks to @jonking-ajar, who found and
67
+ fixed this alongside the cancellation work.
68
+
69
+ ### Security
70
+
22
71
  ## [2.2.12] "Gymkata" - 2026-10-20
23
72
 
24
73
  ### Added
@@ -620,7 +620,7 @@ dependencies = [
620
620
 
621
621
  [[package]]
622
622
  name = "jsonata-core"
623
- version = "2.2.12"
623
+ version = "2.2.13"
624
624
  dependencies = [
625
625
  "assert_cmd",
626
626
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsonata-core"
3
- version = "2.2.12"
3
+ version = "2.2.13"
4
4
  edition = "2021"
5
5
  authors = ["txjmb <txjmb@users.noreply.github.com>"]
6
6
  description = "High-performance Rust implementation of JSONata query and transformation language"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonatapy
3
- Version: 2.2.12
3
+ Version: 2.2.13
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -24,12 +24,12 @@ Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
24
24
  Requires-Dist: ruff>=0.3.0 ; extra == 'dev'
25
25
  Requires-Dist: mypy>=1.0 ; extra == 'dev'
26
26
  Requires-Dist: maturin>=1.0 ; extra == 'dev'
27
- Requires-Dist: fastmcp>=3.0 ; extra == 'dev'
27
+ Requires-Dist: fastmcp>=4.1.2 ; extra == 'dev'
28
28
  Requires-Dist: mkdocs>=1.5 ; extra == 'docs'
29
29
  Requires-Dist: mkdocs-material>=9.0 ; extra == 'docs'
30
30
  Requires-Dist: mkdocstrings[python]>=0.24 ; extra == 'docs'
31
31
  Requires-Dist: pymdown-extensions>=10.0 ; extra == 'docs'
32
- Requires-Dist: fastmcp>=3.0 ; extra == 'mcp'
32
+ Requires-Dist: fastmcp>=4.1.2 ; extra == 'mcp'
33
33
  Provides-Extra: bench
34
34
  Provides-Extra: dev
35
35
  Provides-Extra: docs
@@ -75,13 +75,13 @@ Project-URL: Repository, https://github.com/txjmb/jsonata-core
75
75
  <source media="(prefers-color-scheme: dark)"
76
76
  srcset="https://raw.githubusercontent.com/txjmb/jsonata-core/main/docs/assets/realistic-workload-dark.svg">
77
77
  <img width="880"
78
- alt="Realistic-workload benchmark on a 100-product dataset, lower is better: jsonatapy is 8.7x faster than the jsonata-js reference on the geometric mean of 5 e-commerce queries, and the pure-Rust jsonata-core engine is faster still."
78
+ alt="Realistic-workload benchmark on a 100-product dataset, lower is better: jsonatapy is 8.1x faster than the jsonata-js reference on the geometric mean of 5 e-commerce queries, and the pure-Rust jsonata-core engine is faster still."
79
79
  src="https://raw.githubusercontent.com/txjmb/jsonata-core/main/docs/assets/realistic-workload-light.svg">
80
80
  </picture>
81
81
  </a>
82
82
 
83
83
  *Click the chart for the full category-by-category tables, including jsonata-python
84
- and jsonata-rs, which are left off the chart because at 68 ms–9 s they would
84
+ and jsonata-rs, which are left off the chart because at 67 ms–9 s they would
85
85
  flatten everything else into a sliver.*
86
86
  <!-- END generated performance chart -->
87
87
 
@@ -156,10 +156,10 @@ let result = Evaluator::new().evaluate(&ast, &data)?;
156
156
  ```toml
157
157
  # Cargo.toml
158
158
  [dependencies]
159
- jsonata-core = "2.2.12" # pure Rust, no Python dependency
159
+ jsonata-core = "2.2.13" # pure Rust, no Python dependency
160
160
 
161
161
  # Optional: disable SIMD for constrained targets
162
- jsonata-core = { version = "2.2.12", default-features = false }
162
+ jsonata-core = { version = "2.2.13", default-features = false }
163
163
  ```
164
164
 
165
165
  ---
@@ -26,13 +26,13 @@
26
26
  <source media="(prefers-color-scheme: dark)"
27
27
  srcset="https://raw.githubusercontent.com/txjmb/jsonata-core/main/docs/assets/realistic-workload-dark.svg">
28
28
  <img width="880"
29
- alt="Realistic-workload benchmark on a 100-product dataset, lower is better: jsonatapy is 8.7x faster than the jsonata-js reference on the geometric mean of 5 e-commerce queries, and the pure-Rust jsonata-core engine is faster still."
29
+ alt="Realistic-workload benchmark on a 100-product dataset, lower is better: jsonatapy is 8.1x faster than the jsonata-js reference on the geometric mean of 5 e-commerce queries, and the pure-Rust jsonata-core engine is faster still."
30
30
  src="https://raw.githubusercontent.com/txjmb/jsonata-core/main/docs/assets/realistic-workload-light.svg">
31
31
  </picture>
32
32
  </a>
33
33
 
34
34
  *Click the chart for the full category-by-category tables, including jsonata-python
35
- and jsonata-rs, which are left off the chart because at 68 ms–9 s they would
35
+ and jsonata-rs, which are left off the chart because at 67 ms–9 s they would
36
36
  flatten everything else into a sliver.*
37
37
  <!-- END generated performance chart -->
38
38
 
@@ -107,10 +107,10 @@ let result = Evaluator::new().evaluate(&ast, &data)?;
107
107
  ```toml
108
108
  # Cargo.toml
109
109
  [dependencies]
110
- jsonata-core = "2.2.12" # pure Rust, no Python dependency
110
+ jsonata-core = "2.2.13" # pure Rust, no Python dependency
111
111
 
112
112
  # Optional: disable SIMD for constrained targets
113
- jsonata-core = { version = "2.2.12", default-features = false }
113
+ jsonata-core = { version = "2.2.13", default-features = false }
114
114
  ```
115
115
 
116
116
  ---
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "jsonatapy"
7
- version = "2.2.12"
7
+ version = "2.2.13"
8
8
  description = "High-performance Python/Rust implementation of JSONata query and transformation language"
9
9
  authors = [
10
10
  {name = "txjmb", email = "txjmb@users.noreply.github.com"}
@@ -42,10 +42,10 @@ dev = [
42
42
  "ruff>=0.3.0", # Linting and formatting
43
43
  "mypy>=1.0",
44
44
  "maturin>=1.0",
45
- "fastmcp>=3.0", # So `dev` installs can run the full test suite (Task 9)
45
+ "fastmcp>=4.1.2", # So `dev` installs can run the full test suite (Task 9)
46
46
  ]
47
47
  mcp = [
48
- "fastmcp>=3.0",
48
+ "fastmcp>=4.1.2",
49
49
  ]
50
50
  bench = [
51
51
  "jsonata>=0.2.0", # jsonata-python for benchmarking
@@ -82,7 +82,7 @@ dev = [
82
82
  "ruff>=0.3.0",
83
83
  "mypy>=1.0",
84
84
  "maturin>=1.0",
85
- "fastmcp>=3.0",
85
+ "fastmcp>=4.1.2",
86
86
  ]
87
87
 
88
88
  [tool.ruff]
@@ -491,6 +491,7 @@ pub unsafe extern "C" fn jsonata_set_limits(
491
491
  timeout_ms: (timeout_ms > 0).then_some(timeout_ms),
492
492
  max_stack_depth: (max_stack_depth > 0).then_some(max_stack_depth as usize),
493
493
  max_sequence_length: (max_sequence_length > 0).then_some(max_sequence_length as usize),
494
+ cancel: None,
494
495
  };
495
496
  clear_error();
496
497
  0
@@ -3691,12 +3691,76 @@ impl TupleKeyBindings {
3691
3691
  }
3692
3692
  }
3693
3693
 
3694
+ /// A handle that stops an evaluation from another thread.
3695
+ ///
3696
+ /// Clones share one flag. Put a clone in [`EvaluatorOptions::cancel`] and call
3697
+ /// [`CancelToken::cancel`] on another, and the evaluation raises U1003 at its
3698
+ /// next checkpoint: each node it evaluates, each element a higher-order
3699
+ /// builtin (`$map`, `$filter`, `$reduce`, `$single`, `$sift`, `$each`,
3700
+ /// `$sort`) hands its callback, each match `$replace` hands its replacement
3701
+ /// function, and each pass of a tail-call loop. These are the places
3702
+ /// `timeout_ms` is checked.
3703
+ ///
3704
+ /// A single builtin call over a large input, such as `$string` of a long
3705
+ /// array, finishes before the flag is seen. If that call is the last step,
3706
+ /// the evaluation returns its result even though the token was cancelled, so
3707
+ /// check [`CancelToken::is_cancelled`] after it returns when that matters.
3708
+ ///
3709
+ /// An [`Evaluator`] keeps its options, so one built with a cancelled token
3710
+ /// fails every later evaluation. Pass a fresh token with each call, as below,
3711
+ /// or build a new `Evaluator`.
3712
+ ///
3713
+ /// ```
3714
+ /// use jsonata_core::evaluator::{CancelToken, EvaluatorOptions};
3715
+ /// use jsonata_core::value::JValue;
3716
+ /// use jsonata_core::Expression;
3717
+ ///
3718
+ /// let expr = Expression::compile("($inf := function(){ $inf() }; $inf())")?;
3719
+ ///
3720
+ /// let token = CancelToken::new();
3721
+ /// let canceller = token.clone();
3722
+ /// std::thread::spawn(move || {
3723
+ /// std::thread::sleep(std::time::Duration::from_millis(10));
3724
+ /// canceller.cancel();
3725
+ /// });
3726
+ /// let options = EvaluatorOptions {
3727
+ /// cancel: Some(token),
3728
+ /// ..Default::default()
3729
+ /// };
3730
+ /// let err = expr.evaluate_with_options(&JValue::Null, options).unwrap_err();
3731
+ /// assert!(err.to_string().contains("U1003"));
3732
+ /// # Ok::<(), Box<dyn std::error::Error>>(())
3733
+ /// ```
3734
+ #[derive(Clone, Debug, Default)]
3735
+ pub struct CancelToken(std::sync::Arc<std::sync::atomic::AtomicBool>);
3736
+
3737
+ impl CancelToken {
3738
+ /// A token that has not been cancelled.
3739
+ pub fn new() -> Self {
3740
+ Self::default()
3741
+ }
3742
+
3743
+ /// Stop every evaluation that holds a clone of this token. It cannot be
3744
+ /// reset.
3745
+ pub fn cancel(&self) {
3746
+ self.0.store(true, std::sync::atomic::Ordering::Relaxed);
3747
+ }
3748
+
3749
+ /// Whether [`CancelToken::cancel`] has been called on any clone.
3750
+ pub fn is_cancelled(&self) -> bool {
3751
+ self.0.load(std::sync::atomic::Ordering::Relaxed)
3752
+ }
3753
+ }
3754
+
3694
3755
  /// Resource-limit guardrails, mirroring jsonata-js 2.2.1's `timeout`/`stack`/`sequence`
3695
3756
  /// evaluator options. All fields default to `None` = unlimited (current behavior).
3696
3757
  #[derive(Default, Clone, Debug)]
3697
3758
  pub struct EvaluatorOptions {
3698
3759
  /// Maximum wall-clock evaluation time in milliseconds. Exceeding it raises D1012.
3699
3760
  pub timeout_ms: Option<u64>,
3761
+ /// Stops the evaluation with U1003 once the token is cancelled, from any
3762
+ /// thread. jsonata-js has no equivalent; see [`CancelToken`].
3763
+ pub cancel: Option<CancelToken>,
3700
3764
  /// Maximum evaluation stack depth, like jsonata-js's `stack` option. Exceeding
3701
3765
  /// it raises D1011. Without it, evaluation may nest up to the built-in
3702
3766
  /// `MAX_EVAL_DEPTH` (30,000 levels, about 10,000 non-tail recursive calls),
@@ -4262,14 +4326,29 @@ pub(crate) fn check_sequence_length(
4262
4326
  Ok(())
4263
4327
  }
4264
4328
 
4265
- /// Per-iteration D1012 check for loop-based compiled/VM constructs (map/filter/
4266
- /// reduce element loops, FilterByBytecode) that don't pass through
4267
- /// `evaluate_internal`'s per-node checkpoint and would otherwise run untimed.
4329
+ /// U1003 once the caller has cancelled the evaluation. jsonata-js has no
4330
+ /// cancellation and so no code for it; like U1001 and U1002 this one is ours.
4331
+ #[inline(always)]
4332
+ pub(crate) fn check_cancelled(options: &EvaluatorOptions) -> Result<(), EvaluatorError> {
4333
+ match &options.cancel {
4334
+ Some(token) if token.is_cancelled() => Err(EvaluatorError::EvaluationError(
4335
+ "U1003: Evaluation cancelled".to_string(),
4336
+ )),
4337
+ _ => Ok(()),
4338
+ }
4339
+ }
4340
+
4341
+ /// Per-iteration D1012 and U1003 check for loops that don't pass through
4342
+ /// `evaluate_internal`'s per-node checkpoint and would otherwise run untimed:
4343
+ /// compiled map/filter/reduce element loops, FilterByBytecode, the tail-call
4344
+ /// trampoline, and the higher-order builtins' loops over a builtin, host or
4345
+ /// partial callback, whose call evaluates no node.
4268
4346
  #[inline]
4269
4347
  pub(crate) fn check_loop_timeout(
4270
4348
  options: &EvaluatorOptions,
4271
4349
  start_time: Option<Instant>,
4272
4350
  ) -> Result<(), EvaluatorError> {
4351
+ check_cancelled(options)?;
4273
4352
  if let Some(timeout_ms) = options.timeout_ms {
4274
4353
  if let Some(start) = start_time {
4275
4354
  if start.elapsed().as_millis() as u64 > timeout_ms {
@@ -4915,6 +4994,7 @@ impl Evaluator {
4915
4994
  } else {
4916
4995
  // Unexpected param count - fall back to generic path
4917
4996
  while i < left.len() && j < right.len() {
4997
+ check_loop_timeout(&self.options, self.start_time)?;
4918
4998
  let cmp_result = self.apply_function(
4919
4999
  comparator,
4920
5000
  &[left[i].clone(), right[j].clone()],
@@ -4932,6 +5012,7 @@ impl Evaluator {
4932
5012
  } else {
4933
5013
  // Non-lambda comparator: use generic apply_function path
4934
5014
  while i < left.len() && j < right.len() {
5015
+ check_loop_timeout(&self.options, self.start_time)?;
4935
5016
  let cmp_result =
4936
5017
  self.apply_function(comparator, &[left[i].clone(), right[j].clone()], data)?;
4937
5018
  if js_truthy(&cmp_result) {
@@ -5111,9 +5192,9 @@ impl Evaluator {
5111
5192
  self.with_checkpoint(|ev| ev.evaluate_internal_impl(node, data))
5112
5193
  }
5113
5194
 
5114
- /// Run one level of evaluation under the recursion-depth, timeout and
5115
- /// native-stack checks every node evaluation makes. Inlined so the hot
5116
- /// node-evaluation path pays no extra call frame for it. The trampoline's
5195
+ /// Run one level of evaluation under the recursion-depth, cancellation,
5196
+ /// timeout and native-stack checks every node evaluation makes. Inlined so
5197
+ /// the hot node-evaluation path pays no extra call frame for it. The trampoline's
5117
5198
  /// own recursion paths that bypass `evaluate_internal` (a tail call of a
5118
5199
  /// higher-order builtin or of a composition) run under it too, so no
5119
5200
  /// recursion cycle grows the native stack unchecked (#191).
@@ -5152,6 +5233,11 @@ impl Evaluator {
5152
5233
  ));
5153
5234
  }
5154
5235
 
5236
+ if let Err(e) = check_cancelled(&self.options) {
5237
+ self.recursion_depth -= 1;
5238
+ return Err(e);
5239
+ }
5240
+
5155
5241
  // Check evaluation timeout (D1012). `start_time` is only set (in
5156
5242
  // `evaluate()`) when `options.timeout_ms` is configured, so this is a
5157
5243
  // single `is_none()` branch of overhead when no timeout is set.
@@ -8347,6 +8433,7 @@ impl Evaluator {
8347
8433
  let call_args =
8348
8434
  hof_object_call_args(value, key, obj_value.as_ref(), param_count);
8349
8435
 
8436
+ check_loop_timeout(&evaluator.options, evaluator.start_time)?;
8350
8437
  let pred_result =
8351
8438
  evaluator.apply_function(func_node, &call_args, context_data)?;
8352
8439
  if evaluator.is_truthy(&pred_result) {
@@ -8549,6 +8636,7 @@ impl Evaluator {
8549
8636
  let call_args =
8550
8637
  hof_array_call_args(item, index, arr_value.as_ref(), param_count);
8551
8638
 
8639
+ check_loop_timeout(&self.options, self.start_time)?;
8552
8640
  let mapped = self.apply_function(&args[1], &call_args, data)?;
8553
8641
  // Filter out undefined results but keep explicit null (JSONata map semantics)
8554
8642
  // undefined comes from missing else clause, null is explicit
@@ -8686,6 +8774,7 @@ impl Evaluator {
8686
8774
  let call_args =
8687
8775
  hof_array_call_args(item, index, arr_value.as_ref(), param_count);
8688
8776
 
8777
+ check_loop_timeout(&self.options, self.start_time)?;
8689
8778
  let predicate_result = self.apply_function(&args[1], &call_args, data)?;
8690
8779
  if self.is_truthy(&predicate_result) {
8691
8780
  result.push(item.clone());
@@ -8848,6 +8937,7 @@ impl Evaluator {
8848
8937
  ],
8849
8938
  };
8850
8939
 
8940
+ check_loop_timeout(&self.options, self.start_time)?;
8851
8941
  accumulator = self.apply_function(&args[1], &call_args, data)?;
8852
8942
  }
8853
8943
 
@@ -8908,6 +8998,7 @@ impl Evaluator {
8908
8998
  // hofFuncArgs shaping (see hof_array_call_args)
8909
8999
  let call_args =
8910
9000
  hof_array_call_args(item, index, arr_value.as_ref(), param_count);
9001
+ check_loop_timeout(&self.options, self.start_time)?;
8911
9002
  let predicate_result = self.apply_function(&args[1], &call_args, data)?;
8912
9003
  if self.is_truthy(&predicate_result) {
8913
9004
  matches.push(item.clone());
@@ -8960,6 +9051,7 @@ impl Evaluator {
8960
9051
  let call_args =
8961
9052
  hof_object_call_args(value, key, obj_whole.as_ref(), param_count);
8962
9053
 
9054
+ check_loop_timeout(&self.options, self.start_time)?;
8963
9055
  let fn_result = self.apply_function(func_arg, &call_args, data)?;
8964
9056
  // Skip undefined results only. jsonata-js guards
8965
9057
  // this push with `typeof val !== 'undefined'`, so an
@@ -10223,6 +10315,7 @@ impl Evaluator {
10223
10315
  group,
10224
10316
  );
10225
10317
  }
10318
+ check_loop_timeout(&self.options, self.start_time)?;
10226
10319
  match self.as_callback(|ev| ev.call_function_value(replacement, &[match_object()], data))? {
10227
10320
  JValue::String(replaced) => {
10228
10321
  out.push_str(&replaced);
@@ -343,6 +343,7 @@ impl JsonataExpression {
343
343
  timeout_ms: timeout.or(self.default_options.timeout_ms),
344
344
  max_stack_depth: max_stack_depth.or(self.default_options.max_stack_depth),
345
345
  max_sequence_length: max_sequence_length.or(self.default_options.max_sequence_length),
346
+ cancel: self.default_options.cancel.clone(),
346
347
  }
347
348
  }
348
349
 
@@ -792,6 +793,7 @@ fn build_evaluator_options(
792
793
  timeout_ms: timeout,
793
794
  max_stack_depth,
794
795
  max_sequence_length,
796
+ cancel: None,
795
797
  }
796
798
  }
797
799
 
@@ -199,6 +199,7 @@ impl<'prog> Vm<'prog> {
199
199
  data: &JValue,
200
200
  vars: Option<&HashMap<&str, &JValue>>,
201
201
  ) -> Result<JValue, EvaluatorError> {
202
+ crate::evaluator::check_cancelled(&self.options)?;
202
203
  let data = &data.clone().into_outer_wrapper();
203
204
  let start_time = if self.options.timeout_ms.is_some() {
204
205
  Some(std::time::Instant::now())
@@ -830,4 +831,23 @@ mod tests {
830
831
  .expect_err("expected D1012");
831
832
  assert!(err.to_string().contains("D1012"), "got: {err}");
832
833
  }
834
+
835
+ #[test]
836
+ fn test_filter_by_bytecode_raises_u1003_when_cancelled() {
837
+ let data: JValue = serde_json::json!({
838
+ "items": (0..1000).collect::<Vec<i32>>()
839
+ })
840
+ .into();
841
+ let prog = compile_and_assert_uses_filter_by_bytecode("items[$ >= 0]");
842
+ let token = crate::evaluator::CancelToken::new();
843
+ token.cancel();
844
+ let options = EvaluatorOptions {
845
+ cancel: Some(token),
846
+ ..Default::default()
847
+ };
848
+ let err = Vm::with_options(&prog, options)
849
+ .run(&data, None)
850
+ .expect_err("expected U1003");
851
+ assert!(err.to_string().contains("U1003"), "got: {err}");
852
+ }
833
853
  }
@@ -4,7 +4,7 @@
4
4
  // to process complete JSONata expressions.
5
5
 
6
6
  use jsonata_core::{
7
- evaluator::{Context, Evaluator, EvaluatorOptions},
7
+ evaluator::{CancelToken, Context, Evaluator, EvaluatorOptions},
8
8
  parser::parse,
9
9
  value::JValue,
10
10
  };
@@ -1022,6 +1022,161 @@ fn test_no_timeout_configured_never_raises_d1012() {
1022
1022
  assert!(result.is_ok(), "expected Ok, got: {result:?}");
1023
1023
  }
1024
1024
 
1025
+ /// Options carrying `token`, with a 10 s timeout so a cancel that is never
1026
+ /// seen fails with D1012 instead of hanging the test run.
1027
+ fn cancellable(token: &CancelToken) -> EvaluatorOptions {
1028
+ EvaluatorOptions {
1029
+ cancel: Some(token.clone()),
1030
+ timeout_ms: Some(10_000),
1031
+ ..Default::default()
1032
+ }
1033
+ }
1034
+
1035
+ /// Evaluates `expr_str` with a host function `$tick` that cancels the
1036
+ /// evaluation on its `cancel_on`th call. It returns `"x"`, since a string suits
1037
+ /// every callback slot and a `$replace` replacement must return one. Returns
1038
+ /// the error and how many times `$tick` ran.
1039
+ fn evaluate_cancelled_on_call(
1040
+ expr_str: &str,
1041
+ cancel_on: usize,
1042
+ ) -> (jsonata_core::evaluator::EvaluatorError, usize) {
1043
+ let ast = parse(expr_str).unwrap();
1044
+ let token = CancelToken::new();
1045
+ let calls = std::rc::Rc::new(std::cell::Cell::new(0));
1046
+ let mut evaluator = Evaluator::with_options(Context::new(), cancellable(&token));
1047
+ let (tick_token, tick_calls) = (token.clone(), calls.clone());
1048
+ evaluator
1049
+ .register_fn("tick", move |_args: &[JValue]| {
1050
+ tick_calls.set(tick_calls.get() + 1);
1051
+ if tick_calls.get() == cancel_on {
1052
+ tick_token.cancel();
1053
+ }
1054
+ Ok(JValue::string("x"))
1055
+ })
1056
+ .unwrap();
1057
+ let err = evaluator
1058
+ .evaluate(&ast, &JValue::Null)
1059
+ .expect_err("expected a U1003 cancellation");
1060
+ (err, calls.get())
1061
+ }
1062
+
1063
+ /// A token cancelled before evaluation starts stops it at the first node.
1064
+ #[test]
1065
+ fn test_cancel_before_evaluation_raises_u1003() {
1066
+ let ast = parse("$sum([1..10])").unwrap();
1067
+ let token = CancelToken::new();
1068
+ token.cancel();
1069
+ let err = Evaluator::with_options(Context::new(), cancellable(&token))
1070
+ .evaluate(&ast, &JValue::Null)
1071
+ .expect_err("expected U1003");
1072
+ assert!(
1073
+ err.to_string().contains("U1003"),
1074
+ "expected U1003, got: {err}"
1075
+ );
1076
+ }
1077
+
1078
+ /// A token cancelled before the run stops a compiled `Expression` too, whose
1079
+ /// simple expressions run on the bytecode VM rather than the tree-walker.
1080
+ #[test]
1081
+ fn test_cancel_before_evaluation_raises_u1003_on_compiled_expression() {
1082
+ let token = CancelToken::new();
1083
+ token.cancel();
1084
+ let data = JValue::from(json!({"a": 1}));
1085
+ for expr_str in ["a + 1", "a", "$sum([a, 2])"] {
1086
+ let expr =
1087
+ jsonata_core::Expression::compile_with_options(expr_str, cancellable(&token)).unwrap();
1088
+ let err = expr.evaluate(&data).expect_err("expected U1003");
1089
+ assert!(
1090
+ err.to_string().contains("U1003"),
1091
+ "{expr_str}: expected U1003, got: {err}"
1092
+ );
1093
+ }
1094
+ }
1095
+
1096
+ /// A runaway tail-recursive loop, which has no iteration cap, stops once
1097
+ /// another thread cancels it. The trampoline checks on every pass.
1098
+ #[test]
1099
+ fn test_cancel_from_another_thread_stops_tail_recursion() {
1100
+ let ast = parse("($inf := function(){$inf()}; $inf())").unwrap();
1101
+ let token = CancelToken::new();
1102
+ let canceller = token.clone();
1103
+ let cancel_thread = std::thread::spawn(move || {
1104
+ std::thread::sleep(std::time::Duration::from_millis(20));
1105
+ canceller.cancel();
1106
+ });
1107
+ let err = Evaluator::with_options(Context::new(), cancellable(&token))
1108
+ .evaluate(&ast, &JValue::Null)
1109
+ .expect_err("expected U1003");
1110
+ cancel_thread.join().unwrap();
1111
+ assert!(
1112
+ err.to_string().contains("U1003"),
1113
+ "expected U1003, got: {err}"
1114
+ );
1115
+ }
1116
+
1117
+ /// A cancel stops a higher-order builtin between elements, whether its
1118
+ /// callback is a lambda, whose body is checked like any node, or a host
1119
+ /// function, whose call evaluates no node and is checked by the loop.
1120
+ #[test]
1121
+ fn test_cancel_stops_higher_order_builtins_between_elements() {
1122
+ let cases = [
1123
+ "$map([1..2000], function($x){ $tick($x) })",
1124
+ "$map([1..2000], $tick)",
1125
+ "$filter([1..2000], $tick)",
1126
+ "$reduce([1..2000], $tick)",
1127
+ "$single([1..2000], $tick)",
1128
+ "$sift($merge([1..2000].{$string($): $}), $tick)",
1129
+ "$each($merge([1..2000].{$string($): $}), $tick)",
1130
+ "$sort([1..2000], $tick)",
1131
+ "$replace($pad('', 2000, 'a'), /a/, $tick)",
1132
+ ];
1133
+ for expr_str in cases {
1134
+ let (err, calls) = evaluate_cancelled_on_call(expr_str, 100);
1135
+ assert!(
1136
+ err.to_string().contains("U1003"),
1137
+ "{expr_str}: expected U1003, got: {err}"
1138
+ );
1139
+ assert_eq!(calls, 100, "{expr_str}: $tick ran {calls} times");
1140
+ }
1141
+ }
1142
+
1143
+ /// `timeout_ms` stops `$map` over a host callback too. The loop had no
1144
+ /// check, so a slow callback ran past the deadline to the end of the array.
1145
+ #[test]
1146
+ fn test_timeout_stops_map_over_a_host_callback() {
1147
+ let ast = parse("$map([1..1000], $slow)").unwrap();
1148
+ let options = EvaluatorOptions {
1149
+ timeout_ms: Some(50),
1150
+ ..Default::default()
1151
+ };
1152
+ let mut evaluator = Evaluator::with_options(Context::new(), options);
1153
+ evaluator
1154
+ .register_fn("slow", |args: &[JValue]| {
1155
+ std::thread::sleep(std::time::Duration::from_millis(1));
1156
+ Ok(args.first().cloned().unwrap_or(JValue::Null))
1157
+ })
1158
+ .unwrap();
1159
+ let err = evaluator
1160
+ .evaluate(&ast, &JValue::Null)
1161
+ .expect_err("expected D1012");
1162
+ assert!(
1163
+ err.to_string().contains("D1012"),
1164
+ "expected D1012, got: {err}"
1165
+ );
1166
+ }
1167
+
1168
+ /// A token that is never cancelled changes no result.
1169
+ #[test]
1170
+ fn test_uncancelled_token_changes_nothing() {
1171
+ let ast = parse("$map([1..1000], function($x){$x*2})").unwrap();
1172
+ let token = CancelToken::new();
1173
+ let with_token = Evaluator::with_options(Context::new(), cancellable(&token))
1174
+ .evaluate(&ast, &JValue::Null)
1175
+ .unwrap();
1176
+ let without = Evaluator::new().evaluate(&ast, &JValue::Null).unwrap();
1177
+ assert_eq!(with_token, without);
1178
+ }
1179
+
1025
1180
  /// jsonata-js's own canonical guardrails-documentation example: an infinite
1026
1181
  /// tail-recursive call. `invoke_lambda_with_tco`'s trampoline loop previously
1027
1182
  /// had no timeout check at all -- only a hardcoded 100,000-iteration cap,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes