jsonatapy 2.2.8__tar.gz → 2.2.9__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 (70) hide show
  1. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.gitignore +1 -0
  2. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/architecture.md +6 -3
  3. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/CHANGELOG.md +192 -0
  4. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/Cargo.lock +20 -1
  5. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/Cargo.toml +3 -2
  6. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/PKG-INFO +1 -1
  7. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/bindings/c/README.md +7 -1
  8. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/bindings/c/examples/smoke.c +21 -0
  9. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/bindings/c/jsonata.h +13 -0
  10. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/pyproject.toml +2 -2
  11. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/ast_transform.rs +41 -144
  12. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/bin/jsonata/error_format.rs +1 -9
  13. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/bin/jsonata/main.rs +14 -6
  14. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/builtins.rs +188 -256
  15. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/capi.rs +77 -37
  16. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/compiler.rs +12 -17
  17. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/datetime.rs +2 -20
  18. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/evaluator.rs +663 -1287
  19. jsonatapy-2.2.9/src/expression.rs +180 -0
  20. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/functions.rs +128 -290
  21. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/lazy.rs +93 -28
  22. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/lib.rs +56 -85
  23. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/signature.rs +45 -136
  24. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/value.rs +42 -29
  25. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/vm.rs +33 -71
  26. jsonatapy-2.2.9/tests/lambda_closure_suite.rs +586 -0
  27. jsonatapy-2.2.8/src/parser/README.md +0 -321
  28. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.gitmodules +0 -0
  29. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/.gitignore +0 -0
  30. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/conventions.md +0 -0
  31. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/core.md +0 -0
  32. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/memory_maintenance.md +0 -0
  33. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/suggested_commands.md +0 -0
  34. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/task_completion.md +0 -0
  35. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/memories/tech_stack.md +0 -0
  36. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/.serena/project.yml +0 -0
  37. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/LICENSE +0 -0
  38. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/README.md +0 -0
  39. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/benches/evaluator_bench.rs +0 -0
  40. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/examples/evaluator_demo.rs +0 -0
  41. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/examples/host_functions.rs +0 -0
  42. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/examples/parser_demo.rs +0 -0
  43. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/examples/simd_json_bench.rs +0 -0
  44. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/__init__.py +0 -0
  45. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/__main__.py +0 -0
  46. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/_cli/__init__.py +0 -0
  47. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/_cli/bindings.py +0 -0
  48. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/_cli/error_format.py +0 -0
  49. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/_cli/mcp_server.py +0 -0
  50. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/_cli/resolve.py +0 -0
  51. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/_cli/run.py +0 -0
  52. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/python/jsonatapy/py.typed +0 -0
  53. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/ast.rs +0 -0
  54. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/bin/jsonata/bindings.rs +0 -0
  55. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/bin/jsonata/resolve.rs +0 -0
  56. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/src/parser.rs +0 -0
  57. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/study/cli_fixtures.json +0 -0
  58. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/study/cli_fixtures_testdata.json +0 -0
  59. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/study/cli_spec.md +0 -0
  60. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/cli_fixtures_test.rs +0 -0
  61. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/cli_test.rs +0 -0
  62. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/datetime_picture_suite.rs +0 -0
  63. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/fixtures/builtin_arity.json +0 -0
  64. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/fixtures/builtin_differential.json +0 -0
  65. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/fixtures/builtin_signatures.json +0 -0
  66. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/fixtures/fastpath_differential.json +0 -0
  67. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/fixtures/fastpath_known_divergences.json +0 -0
  68. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/host_functions_test.rs +0 -0
  69. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/integration_test.rs +0 -0
  70. {jsonatapy-2.2.8 → jsonatapy-2.2.9}/tests/parent_and_focus_binding_suite.rs +0 -0
@@ -71,3 +71,4 @@ benchmarks/javascript/package-lock.json
71
71
  # Temporary files
72
72
  *.tmp
73
73
  *.bak
74
+ *.exe
@@ -45,9 +45,12 @@ site (grep `unwrap_or(JValue::Null)` / `JValue::Null =>` near field-access code)
45
45
  ### Compilation pipeline (bytecode fast path)
46
46
  `evaluator::try_compile_expr(ast)` → `CompiledExpr` (IR) → `compiler::BytecodeCompiler::compile`
47
47
  → `BytecodeProgram` (flat `Vec<Instr>`) → `peephole()` folds (`PushData+GetField` →
48
- `GetDataField`, `GetVar+GetField` → `GetVarField`, elides `Not+Not`) → `vm::Vm::new(bc).run(...)`.
49
- `JsonataExpression` (lib.rs) caches `bytecode: OnceCell<Option<BytecodeProgram>>`; all 4 evaluate
50
- entrypoints try the VM first and fall back to the tree-walker.
48
+ `GetDataField`, `GetVar+GetField` → `GetVarField`, elides `Not+Not`) →
49
+ `vm::Vm::with_options(bc, options).run(...)`.
50
+ `JsonataExpression` (lib.rs) caches `bytecode: OnceCell<Option<BytecodeProgram>>`; all 5 evaluate
51
+ entrypoints funnel through `run_eval`, which tries the VM first and falls back to the tree-walker.
52
+ The `vm`/`compiler` modules exist only under the `python`, `capi`, or `bench` features (cfg-gated
53
+ in lib.rs) — a default-feature build, including the Rust CLI, always uses the tree-walker.
51
54
 
52
55
  Key correctness rules baked into the compiler/VM (violate these and specific test-suite groups
53
56
  regress):
@@ -19,6 +19,198 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
19
 
20
20
  ### Security
21
21
 
22
+ ## [2.2.9] "Perform-ata" - 2026-08-31
23
+
24
+ ### Changed
25
+ - Lambda values now carry their closure directly — `JValue::Lambda(Rc<StoredLambda>)`
26
+ instead of a `lambda_id` name tag resolved through a per-scope side table (#157).
27
+ The `Rc` provides the closure's lifetime, so the side table (`Scope.lambdas`),
28
+ the escape-analysis GC (`extract_lambda_ids`/`collect_lambda_ids` walking every
29
+ block/lambda result, `pop_scope_preserving_lambdas`) and the dual-map handling in
30
+ `unbind`/`clear_current_scope` are gone, along with the O(result size) walk on
31
+ every scope pop. Recursion is late-bound letrec: a closure defined as
32
+ `$f := function ...` records its own name and binds it to itself at each
33
+ invocation, so no `Rc` cycles exist and closures cannot leak (guarded by a
34
+ live-count test). Free-variable capture remains a by-value snapshot at
35
+ definition time, now applied uniformly to lambda-valued variables too.
36
+ Behavioral fixes (all matching jsonata-js, pinned in
37
+ `tests/lambda_closure_suite.rs`): a transform bound to a variable is callable
38
+ and pipeable (`$t := |...|...|; $t(x)`, `x ~> $t`); a partial application
39
+ escaping the scope that defined its target function works; an alias
40
+ (`$g := $f`) survives `$f` being rebound; calling a name rebound to a
41
+ non-function raises T1006 instead of silently invoking the stale lambda; a
42
+ closure captured inside another escaping closure's environment no longer
43
+ dangles. Function equality is now closure identity rather than id-string
44
+ equality. Remaining known divergences from jsonata-js's live-frame capture
45
+ are documented in the same suite's `divergent_from_reference` module.
46
+ - Partial application is a typed representation instead of a stringly-typed
47
+ protocol: `StoredLambda` carries an optional `PartialApplication { target,
48
+ bound_args, placeholder_positions, total_args }`, replacing the
49
+ `"__partial_call:name:bool:n"` marker-string body that was parsed at every
50
+ invocation and the `__bound_arg_N` / `__placeholder_positions` /
51
+ `__total_args` / `__partial_target` values smuggled through `captured_env`.
52
+ A user-defined target is a captured closure (`PartialTarget::Lambda`);
53
+ builtins/host functions stay name-resolved at call time
54
+ (`PartialTarget::Named`), preserving call-time shadowing. Partials no longer
55
+ snapshot the entire environment at creation (the old
56
+ `capture_current_environment()` call was only ever a smuggling container),
57
+ and one behavior corner now matches jsonata-js: rebinding a user function
58
+ after partially applying it no longer changes what the partial calls
59
+ (pinned in `tests/lambda_closure_suite.rs` with eight other partial pins).
60
+
61
+ ### Added
62
+ - `jsonata_set_limits` on the C ABI: wall-clock timeout (D1012), max AST recursion
63
+ depth (D1011), and max sequence length (D2015) — the same three guardrails the
64
+ Python bindings expose. C embedders previously had no way to bound a runaway
65
+ expression at all (the cleanup review's finding); 0 = unlimited, resettable per
66
+ handle. Covered by the C/C++ smoke tests and a Rust-side capi test.
67
+ - `jsonata_core::Expression` — a compile-once API for Rust callers that runs
68
+ compilable expressions on the bytecode VM, exactly like the Python and C
69
+ bindings always have. Until now the VM was unreachable from the pure-Rust
70
+ surface: `Evaluator` always tree-walks, so Rust (and CLI) callers silently
71
+ got the slower path. `Expression::compile(src)?.evaluate(&data)` lowers to
72
+ bytecode lazily and falls back to the tree-walker for non-compilable
73
+ expressions; bindings and host functions still go through `Evaluator`. The
74
+ dispatch now lives in one place (`expression::run_compiled`), shared by the
75
+ Rust API, the Python bindings' `run_eval`, and the C ABI (which previously
76
+ carried a hand-copied duplicate of it), and the CLI uses it when no `--arg`
77
+ bindings are given. The `vm`/`compiler` modules are unconditionally compiled
78
+ again (the cfg gates added earlier in this cycle are gone — the default
79
+ build now has a real consumer).
80
+
81
+ ### Changed
82
+ - Python→Rust data conversion — the cost that dominates `evaluate(dict)` on array-heavy
83
+ inputs — is faster on two fronts. `lazy::convert` now dispatches on the exact type object
84
+ and iterates lists via borrowed references instead of pyo3's instance-check chain
85
+ (subclasses still take the original chain, so semantics, error types included, are
86
+ unchanged; pinned by `tests/python/test_convert_fastpath.py`). And the published wheels
87
+ now use mimalloc as the global allocator (new opt-in `mimalloc` cargo feature, enabled in
88
+ `pyproject.toml`), roughly halving the per-list/per-object allocation cost that JValue's
89
+ Rc-per-container representation makes the conversion floor. Measured together on the
90
+ benchmark suite's shapes: "Nested Array Access" (`data[1][1][1][1]`, the one row where
91
+ jsonata-js was still ahead) drops ~35% (12.0µs → 7.8µs on the dev container), the four
92
+ `lazy_check.py` gate rows drop 10–17%, and string-heavy conversion roughly halves. The
93
+ structural fix for the nested-array row — lazy list views — remains deferred (see the
94
+ 2026-07-12 lazy-views spec's Limitations).
95
+ - The `vm` and `compiler` modules (the bytecode pipeline) are now cfg-gated on the features
96
+ that actually consume them (`python`, `capi`, `bench`) — they were compiled but unreachable
97
+ in a default-feature build, producing 12 permanent dead-code warnings that CI's
98
+ `--all-features` clippy never saw. A default `cargo build` is now warning-clean, and both
99
+ modules carry a header note naming their consumers. List and dict *subclasses* now take the
100
+ boundary converter's fast path too (`PyList_Check`/`PyDict_Check`; iteration was already
101
+ C-level for them, so behavior is unchanged), which let the duplicate slow-path container
102
+ loops be deleted.
103
+
104
+ ### Changed (evaluator internals)
105
+ - The vestigial `*_is_explicit_null` plumbing is gone: since the null/undefined split
106
+ (#32) a runtime `JValue::Null` is always an explicit null, yet the flags were still
107
+ computed and threaded through ~20 signatures — nine arithmetic/comparison wrapper
108
+ methods, the `CompiledExpr::ExplicitNull` variant (now `Literal(Null)`), a
109
+ `PushExplicitNull` instruction, and per-instruction bool pairs on the VM's
110
+ arithmetic/comparison opcodes — with both terminal consumers ignoring them (~200
111
+ lines removed). Arithmetic and ordered comparison now have exactly one
112
+ implementation each, shared by the tree-walker, the compiled path, and the VM; as
113
+ part of that, the compiled/VM paths' T2010/T2009 error texts now match the
114
+ tree-walker's richer messages ("Cannot compare X and Y", operator symbol in T2009)
115
+ instead of the generic "Type mismatch in comparison" — same codes, better text, and
116
+ the two engines no longer disagree. `Evaluator::is_truthy` likewise now delegates to
117
+ the shared `compiled_is_truthy` instead of maintaining a twin (#111 had to be fixed
118
+ in both).
119
+ - More single-definition consolidation: the two verbatim 45-line signature-error
120
+ translation blocks (direct vs TCO lambda invocation) are one `coerce_lambda_args`
121
+ helper; the five copies of jsonata-js's `hofFuncArgs` argument shaping are two
122
+ helpers (`hof_array_call_args` for $map/$filter/$single, `hof_object_call_args` for
123
+ $sift/$each); the six copy-pasted encoding builtin arms share `unary_string_encoding`.
124
+ `signature.rs` dropped its dead `return_type` field and test-only constructors, and
125
+ a builtin signature that fails to parse now panics at cache build instead of leaving
126
+ that builtin silently unvalidated. `ast_transform.rs`'s 157-line module header — a
127
+ stale task journal whose ~20 line references were all wrong — is a 30-line statement
128
+ of the actual recursion/drop invariants, and `push_ast_node_children`'s leaf arm
129
+ lists every variant explicitly instead of `_ => {}`, so a future `AstNode` variant
130
+ cannot silently opt out of the stack-overflow-on-drop guard.
131
+ - Five hand-maintained parallel lists of builtin names (the pure set, the arity table,
132
+ the compilable set, the signature table, and a test's own literal copy) are one
133
+ sorted `builtins::BUILTINS` spec table; `is_pure_builtin`, `is_compilable_builtin`,
134
+ `builtin_arity`, and `signature::builtin_signature` all derive from it, and the
135
+ jsonata-js fixture drift tests still police the data. Adding a builtin is now one
136
+ row plus its dispatch arm. Membership and values are unchanged (the table was
137
+ generated mechanically from the lists it replaces).
138
+ - The bare-`AstNode::Name` arm of `evaluate_internal_impl` — a private field-access
139
+ copy that had drifted (kept nulls, never flattened, no tuple or lazy-dict handling)
140
+ and that instrumentation shows no expression in any suite reaches — now delegates
141
+ to `compiled_field_step`, the semantics owner for a single field step.
142
+
143
+ ### Deprecated
144
+
145
+ ### Removed
146
+ - Repository hygiene: the checked-in `rustup-init.exe` (12.9 MB Windows installer that
147
+ dominated clone size and permanently tripped the file-size lint), the orphaned root
148
+ `node_modules/jsonata/` copy (~830 KB; nothing referenced it — the benchmark harness
149
+ installs its own under `benchmarks/javascript/`, and the reference implementation is the
150
+ `tests/jsonata-js` submodule), and the stale `src/parser/README.md` (every structural
151
+ claim in it — line counts, ranges, even the crate name in its example — was wrong).
152
+ - Dead code that survived the 2.2.8 builtins consolidation: `functions::numeric::{max,
153
+ min, average}` and `functions::object::{keys, lookup}` had no callers and encoded
154
+ pre-#109 semantics the engine no longer has (`Null` instead of `Undefined` on empty
155
+ input, no nested-array recursion) — a trap for anyone fixing a `$max`/`$lookup` bug in
156
+ the wrong place. Their tests, which pinned the wrong behavior, went with them. Also
157
+ removed the unused `datetime::parse_iso8601`. Technically these were `pub` items of the
158
+ crate, so strict semver would call this a breaking change; they were never used by the
159
+ engine itself.
160
+
161
+ ### Fixed
162
+ - Two families of tree-walker drift found by unifying the remaining field-extraction
163
+ loops onto `compiled_field_step` (each pinned on BOTH engines in
164
+ `tests/python/test_field_step_and_arraygroup.py` with jsonata-js reference outputs):
165
+ the single-step-Name fast path skipped null-valued fields and returned Null on empty
166
+ (`p` over `[{"p":null},{"p":2}]` was `2` on the tree-walker, `[null,2]` on the VM and
167
+ in the reference — the engines disagreed); and the `.[...]` array-group constructor
168
+ kept undefined elements as null (`foo.blah.[baz]` gave `[[..],[null],[null]]` instead
169
+ of `[[..],[],[]]`, masked downstream by that null-skip), while its empty results
170
+ conflated "constructed empty array over a single value" (kept: `{"a":1}.[b]` is `[]`)
171
+ with "mapped over an empty array" (undefined: `emptyarr.[b]`). Both tree-walker
172
+ Name-step loops (the fast path and the general step loop's inner loop) now delegate
173
+ to `compiled_field_step`, deleting ~100 lines of drifted copies; the reference
174
+ suite's `array-constructor/case013`/`case014` now pass by the correct route. The
175
+ fast path's tuple branch (a third copy, with the same null-skip drift) is deleted
176
+ outright — tuple streams fall through to the general step loop's single tuple
177
+ implementation, and `$#$i.p` over `[{"p":null},{"p":2}]` is now `[null,2]` on both
178
+ engines, matching the reference.
179
+ - Errors carry their JSONata spec code at the front of the message again: the
180
+ `FunctionError` wrapping used to bury codes behind prose prefixes ("Runtime
181
+ error: D3030: Cannot convert 'x' to number"), so the C ABI, the Rust CLI, and
182
+ the Python CLI each grew a different classifier and disagreed on whether the
183
+ same error was coded. The inner message now passes through unwrapped
184
+ ("D3030: ..."), `EvaluatorError::code()` / `evaluator::error_code_prefix` is
185
+ the one definition of "coded error" (prefix-anchored), and the C ABI and both
186
+ CLIs classify with it — the same failure now presents identically in every
187
+ binary. The C ABI's mid-string code scan is retired (it existed only to see
188
+ through the now-removed prose prefixes).
189
+ - Number stringification now matches jsonata-js exactly (verified against the pinned
190
+ reference with a 307-case randomized differential over scalars, containers,
191
+ prettified output, and `&` concatenation). Three divergences fixed: non-integers
192
+ were truncated to 14 significant digits instead of rounded to 15 (`$string(1/3)`
193
+ was `0.33333333333333`, now `0.333333333333333` — the old formatter counted the
194
+ `0` of a leading `0.` as significant); integer-valued floats above 2^53 printed
195
+ their exact i64 digits instead of the float's shortest round-trip decimal; and
196
+ numbers nested in containers went through serde with different rules again.
197
+ `$string` now stringifies through its own writer implementing the reference's
198
+ replacer (functions → `""`, non-integers → `toPrecision(15)`), and one
199
+ `value::js_number_to_string` defines JS number printing (plain digits in
200
+ [1e-6, 1e21), exponential with `+` outside, `-0` as `0`) for `$string`, concat,
201
+ `Display`, and `$join`. Pinned by `tests/python/test_number_stringification.py`.
202
+ - `$var.field` over an array containing nested-array elements now recurses into
203
+ them like jsonata-js's `lookup`: `($v := [[{"p":1}],{"p":2}]; $v.p)` is `[1,2]`
204
+ (previously `2` — the tree-walker's two-step fast path hand-rolled its mapping
205
+ loop and silently skipped non-object elements; it now delegates to the shared
206
+ field step). Found while consolidating the seven divergent field-extraction
207
+ loops the cleanup review flagged; pinned by `tests/python/test_var_field_nested.py`.
208
+ - Regex literals now honor the `m` (multiline) flag in `$match` and the `~>`
209
+ chain-pipe, matching `$split`/`$replace` and jsonata-js — the two paths
210
+ previously translated only `i` (see `tests/python/test_regex_flags.py`).
211
+
212
+ ### Security
213
+
22
214
  ## [2.2.8] "Conform-ata" - 2026-08-25
23
215
 
24
216
  Primarily a conformance release, with documentation work on the Rust crate alongside it. Most
@@ -586,7 +586,7 @@ dependencies = [
586
586
 
587
587
  [[package]]
588
588
  name = "jsonata-core"
589
- version = "2.2.8"
589
+ version = "2.2.9"
590
590
  dependencies = [
591
591
  "assert_cmd",
592
592
  "base64",
@@ -594,6 +594,7 @@ dependencies = [
594
594
  "clap",
595
595
  "criterion",
596
596
  "indexmap",
597
+ "mimalloc",
597
598
  "num-traits",
598
599
  "percent-encoding",
599
600
  "predicates",
@@ -614,6 +615,15 @@ version = "0.2.186"
614
615
  source = "registry+https://github.com/rust-lang/crates.io-index"
615
616
  checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
616
617
 
618
+ [[package]]
619
+ name = "libmimalloc-sys"
620
+ version = "0.1.49"
621
+ source = "registry+https://github.com/rust-lang/crates.io-index"
622
+ checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
623
+ dependencies = [
624
+ "cc",
625
+ ]
626
+
617
627
  [[package]]
618
628
  name = "linux-raw-sys"
619
629
  version = "0.12.1"
@@ -632,6 +642,15 @@ version = "2.8.3"
632
642
  source = "registry+https://github.com/rust-lang/crates.io-index"
633
643
  checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
634
644
 
645
+ [[package]]
646
+ name = "mimalloc"
647
+ version = "0.1.52"
648
+ source = "registry+https://github.com/rust-lang/crates.io-index"
649
+ checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
650
+ dependencies = [
651
+ "libmimalloc-sys",
652
+ ]
653
+
635
654
  [[package]]
636
655
  name = "normalize-line-endings"
637
656
  version = "0.3.0"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsonata-core"
3
- version = "2.2.8"
3
+ version = "2.2.9"
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"
@@ -29,7 +29,6 @@ exclude = [
29
29
  "CLAUDE.MD",
30
30
  "pyproject.toml",
31
31
  "uv.lock",
32
- "rustup-init.exe",
33
32
  ]
34
33
 
35
34
  [lib]
@@ -56,6 +55,7 @@ rand = "0.10"
56
55
  stacker = "0.1"
57
56
  simd-json = { version = "0.18", optional = true }
58
57
  clap = { version = "4.6", features = ["derive"], optional = true }
58
+ mimalloc = { version = "0.1.52", optional = true }
59
59
 
60
60
  [features]
61
61
  default = ["simd"]
@@ -64,6 +64,7 @@ python = ["dep:pyo3"]
64
64
  cli = ["dep:clap"]
65
65
  bench = [] # exposes _bench facade for Criterion benchmarks
66
66
  capi = []
67
+ mimalloc = ["dep:mimalloc"]
67
68
 
68
69
  [dev-dependencies]
69
70
  criterion = "0.8"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jsonatapy
3
- Version: 2.2.8
3
+ Version: 2.2.9
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -141,7 +141,13 @@ Rust library rebuilds with your project.)
141
141
  deliberately (e.g. a frozen `$now`, or a disabled `$eval`). `user_data`
142
142
  must outlive the handle. Registering a host function routes evaluation
143
143
  through the tree-walker, like variable bindings.
144
- 8. **Panics:** internal engine panics are caught at the boundary and
144
+ 8. **Guardrails:** `jsonata_set_limits(expr, timeout_ms, max_stack_depth,
145
+ max_sequence_length)` bounds wall-clock time (D1012), AST recursion
146
+ depth (D1011), and query-result sequence length (D2015) for all
147
+ subsequent evaluations on that handle — the same three limits the
148
+ Python bindings expose. 0 means "unlimited" (the default); call again
149
+ to change or reset.
150
+ 9. **Panics:** internal engine panics are caught at the boundary and
145
151
  surface as errors prefixed `internal error:` — they will not abort
146
152
  your process.
147
153
 
@@ -112,6 +112,27 @@ int main(void) {
112
112
  jsonata_free_expr(e);
113
113
  }
114
114
 
115
+ /* evaluation guardrails */
116
+ {
117
+ JsonataExpr *e = jsonata_compile("$map([1..100000], function($x) { $x })");
118
+ CHECK(jsonata_set_limits(NULL, 0, 0, 0) == -1, "set_limits NULL handle -> -1");
119
+ CHECK(jsonata_set_limits(e, 0, 0, 10) == 0, "set_limits succeeds");
120
+ char *r = jsonata_evaluate(e, "{}");
121
+ char *err = take_error();
122
+ char *code = jsonata_last_error_code();
123
+ CHECK(r == NULL && err != NULL, "sequence limit -> NULL + message");
124
+ CHECK(code != NULL && strcmp(code, "D2015") == 0,
125
+ "sequence limit -> spec code D2015");
126
+ jsonata_free_string(err);
127
+ jsonata_free_string(code);
128
+ /* lifting the limit makes the same handle work again */
129
+ CHECK(jsonata_set_limits(e, 0, 0, 0) == 0, "set_limits reset succeeds");
130
+ char *r2 = jsonata_evaluate(e, "{}");
131
+ CHECK(r2 != NULL, "evaluate succeeds after limit reset");
132
+ jsonata_free_string(r2);
133
+ jsonata_free_expr(e);
134
+ }
135
+
115
136
  /* variable binding */
116
137
  {
117
138
  JsonataExpr *e = jsonata_compile("$sum($xs) + n");
@@ -110,6 +110,19 @@ int jsonata_register_fn(JsonataExpr *expr, const char *name,
110
110
  int jsonata_register_fn_override(JsonataExpr *expr, const char *name,
111
111
  jsonata_host_fn fn, void *user_data);
112
112
 
113
+ /*
114
+ * Set evaluation guardrails, applied to every subsequent jsonata_evaluate()
115
+ * on this handle. Each limit uses 0 for "unlimited" (the default):
116
+ * - timeout_ms bounds wall-clock evaluation time (D1012 on breach)
117
+ * - max_stack_depth bounds AST recursion depth (D1011)
118
+ * - max_sequence_length bounds query-result sequences (D2015)
119
+ * These are the same three guardrails the Python bindings expose.
120
+ * Returns 0 on success, -1 on a NULL handle (error slot set).
121
+ */
122
+ int jsonata_set_limits(JsonataExpr *expr, unsigned long long timeout_ms,
123
+ unsigned long long max_stack_depth,
124
+ unsigned long long max_sequence_length);
125
+
113
126
  /* Free a handle returned by jsonata_compile(). NULL is a no-op. */
114
127
  void jsonata_free_expr(JsonataExpr *expr);
115
128
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "jsonatapy"
7
- version = "2.2.8"
7
+ version = "2.2.9"
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"}
@@ -68,7 +68,7 @@ Repository = "https://github.com/txjmb/jsonata-core"
68
68
  [tool.maturin]
69
69
  python-source = "python"
70
70
  module-name = "jsonatapy._jsonatapy"
71
- features = ["pyo3/extension-module", "python"]
71
+ features = ["pyo3/extension-module", "python", "mimalloc"]
72
72
 
73
73
  [dependency-groups]
74
74
  dev = [
@@ -3,150 +3,31 @@
3
3
  // (tests/jsonata-js/src/parser.js ~L937-1235), adapted to Rust's ownership
4
4
  // model: instead of mutating tree nodes in place, this consumes the raw
5
5
  // tree and rebuilds an enriched one with ancestor/tuple metadata resolved.
6
-
7
- // Recursion-depth safety (added: see docs/superpowers/plans/2026-07-07-parser-depth-and-u16-truncation-fixes-plan.md):
8
- // `parser::parse()` (src/parser.rs:1730) unconditionally pipes every parse
9
- // through `resolve_ancestry` below, so a deeply-nested input expression can
10
- // overflow the native stack here even though the raw Pratt parse itself
11
- // completed successfully -- confirmed empirically: a 200,000-term
12
- // left-nested arithmetic chain (`1+1+1+...`) SIGABRTs ("stack overflow")
13
- // via the full `parser::parse()` entry point, in this file, not the parser.
14
- //
15
- // There are THREE recursive pieces in this file, but only TWO independent
16
- // stack budgets: data flows one-way from resolve_ancestry into
17
- // transform_node/transform_children/transform_path_steps/
18
- // migrate_binding_markers ("cycle 1"), and separately into substitute_labels
19
- // ("cycle 2", a second full-tree walk that only starts after cycle 1 has
20
- // fully unwound -- see resolve_ancestry). walk_backward/seek_parent_step/
21
- // seek_parent_wrapped ("cycle 3") is reached FROM cycle 1 (transform_path_
22
- // steps's predicate/own-pending resolution, and transform_children's Sort
23
- // arm) while cycle 1's frames are still LIVE on the native stack -- it nests
24
- // ON TOP of cycle 1's depth rather than running after it -- so cycle 1 and
25
- // cycle 3 share one stack budget and their depths ADD, not two independent
26
- // caps. A depth guard that gives cycle 1 and cycle 3 each their own
27
- // independent counter capped at the native-safe limit would still allow
28
- // cap1 + cap3 frames live simultaneously and overflow; Task 2 needs ONE
29
- // counter threaded through cycle 1 AND cycle 3 together, and a SEPARATE
30
- // counter (reset to 0) for cycle 2 (substitute_labels), which only runs
31
- // after cycle 1/3's frames are gone. Guard all of the functions listed
32
- // below regardless of which cycle they're in -- checking depth in only one
33
- // cycle's functions is the exact "Task 5 pattern" (a check added at only
34
- // one of several recursive entry points) this task exists to avoid.
35
6
  //
36
- // (1) Main tree-transform mutual recursion -- depth scales with the general
37
- // AST's nesting depth (binary op chains, block/array/function-arg
38
- // nesting, parenthesized sub-paths used as a path step's node, etc.):
39
- // - transform_node (:558) -- recurses directly (Path -> transform_path_steps;
40
- // Block -> transform_node per element, a loop, but each iteration's call
41
- // itself recurses; Binary{FocusBind/IndexBind} -> transform_node(lhs));
42
- // for every other node kind, delegates to transform_children (still the
43
- // same cycle). This is the actual site hit by the confirmed arithmetic-
44
- // chain repro (`1+1+1+...` has no Path/`%` at all -- it's pure nested
45
- // Binary, handled by transform_node's `other => transform_children(...)`
46
- // fallback).
47
- // - transform_children (:653) -- recurses via transform_node on every
48
- // child of every composite node type (Binary lhs/rhs, Unary operand,
49
- // Array/Function/Call-args/Object/ObjectTransform/Sort/Transform/
50
- // ArrayGroup elements, Conditional branches, Lambda body, Predicate/
51
- // FunctionApplication inner). This is the other function actually hit
52
- // by the arithmetic-chain repro (Binary's lhs/rhs recursion).
53
- // - transform_path_steps (:933) -- does NOT recurse on the flat
54
- // `Vec<PathStep>` itself (that's a `for` loop over the steps -- bounded
55
- // iteration, not stack depth; confirmed empirically in Step 3 below: a
56
- // 50,000-step flat dot-path parses fine). It DOES feed back into the
57
- // cycle per-step: calls `migrate_binding_markers(step, ...)` for every
58
- // step, and separately calls `transform_node` on each filter-stage
59
- // expression. Depth here scales with how deeply a single step's OWN
60
- // node is nested (e.g. a parenthesized sub-path `(Order.Product)` used
61
- // as one step, itself containing another Path), not with the number of
62
- // steps in the flat list.
63
- // - migrate_binding_markers (:1224) -- not itself self-recursive (one
64
- // match, each arm calls transform_node/splice_marker_steps once), but
65
- // it's the edge that closes the transform_path_steps -> transform_node
66
- // cycle, so it needs to participate in whatever depth-counter scheme
67
- // Task 2 uses (thread it through, even if it never increments/checks
68
- // independently of the transform_node call it makes).
7
+ // ── Recursion-depth safety (the invariant this file must preserve) ──────────
69
8
  //
70
- // (2) substitute_labels (:273) -- self-recursive only (never calls
71
- // transform_node/transform_children/transform_path_steps), structurally
72
- // mirroring transform_children's per-node-type dispatch (every
73
- // composite node type recurses into every child). Runs as a SECOND,
74
- // separate full-tree walk after transform_node returns (see
75
- // resolve_ancestry), so it needs its own depth counter/reset -- reusing
76
- // a counter left over (at whatever depth) from pass (1) would be wrong.
9
+ // `parser::parse()` pipes every parse through `resolve_ancestry`, so a deeply
10
+ // nested input can overflow the native stack HERE even when the Pratt parse
11
+ // succeeded (confirmed empirically with a 200,000-term `1+1+1+...` chain).
12
+ // See docs/superpowers/plans/2026-07-07-parser-depth-and-u16-truncation-fixes-plan.md
13
+ // for the full derivation. The rules, in short:
77
14
  //
78
- // (3) Ancestor-seek recursion, reached from pass (1) (transform_path_steps's
79
- // predicate/own-pending resolution loop calling resolve_predicate_slot/
80
- // walk_backward, and transform_children's Sort arm calling
81
- // walk_backward directly) while pass (1)'s own frames are still live --
82
- // it never calls back into transform_node/transform_children/
83
- // transform_path_steps (a one-way bridge, not a mutual cycle with (1)),
84
- // but because it nests ON TOP of (1)'s live stack rather than running
85
- // after it unwinds, (1) and (3) share ONE stack budget (see the note
86
- // above the fold -- their depths add). Depth here scales with how many
87
- // levels of parenthesized sub-path nesting (`(...)` wrapping another
88
- // `(...)`) a `%` reference has to walk through, not with path step
89
- // count or general AST depth:
90
- // - walk_backward (:1056) -- its own "while level > 0" loop walking
91
- // backward through one `&mut [PathStep]` is bounded iteration (not a
92
- // stack risk regardless of the slice's length), but it calls
93
- // seek_parent_step per candidate step, which can call back into
94
- // walk_backward (via seek_parent_wrapped's Path case) -- indirect
95
- // recursion.
96
- // - seek_parent_step (:1121) -- recurses via seek_parent_wrapped for the
97
- // FunctionApplication and Block step-node cases (a parenthesized
98
- // sub-path used as a step).
99
- // - seek_parent_wrapped (:1191) -- recurses via walk_backward (Path case)
100
- // AND directly calls itself (Block case, recursing into the block's
101
- // last expression) -- e.g. doubly (or N-ly) nested parens.
102
- // - resolve_predicate_slot (:1028) -- NOT part of this cycle itself (no
103
- // self-loop; called once per predicate slot from transform_path_steps's
104
- // loop over a bounded number of stages), but forwards into it
105
- // (seek_parent_step / walk_backward), so its own frame sits at the
106
- // base of chain (3) each time -- no guard needed in this function
107
- // itself, but Task 2 should not assume the chain "starts" at
108
- // walk_backward/seek_parent_step without going through here first in
109
- // the predicate case.
15
+ // 1. There are three recursive cycles but only TWO independent stack budgets.
16
+ // Cycle 1 (transform_node / transform_children / transform_path_steps /
17
+ // migrate_binding_markers) and cycle 3 (walk_backward / seek_parent_step /
18
+ // seek_parent_wrapped) nest on the SAME native stack — cycle 3 is reached
19
+ // while cycle 1's frames are live — so they must share ONE depth counter.
20
+ // Cycle 2 (substitute_labels) runs only after cycle 1 has fully unwound
21
+ // and gets its own counter.
110
22
  //
111
- // Functions confirmed NOT to need guarding (either non-recursive, or their
112
- // only "recursion" is bounded iteration over a Vec/HashMap-chain, not stack
113
- // depth):
114
- // - coded (:161), AncestryState::new (:207), AncestryState::fresh_label
115
- // (:214), Transformed::leaf (:243) -- trivial constructors/helpers, no
116
- // recursive or child-node-walking calls at all.
117
- // - AncestryState::canonical (:224) -- a `while let Some(...)` loop
118
- // following an alias chain in a HashMap; iteration, not recursion, and
119
- // the doc comment right above it already notes chains longer than one
120
- // hop shouldn't arise in practice regardless.
121
- // - apply_marker_to_step (:419), check_focus_bind_target (:454) -- single
122
- // match/if-chain over already-computed values, no calls back into any
123
- // tree-walking function.
124
- // - splice_marker_steps (:486) -- loops over a `Vec<PathStep>` produced by
125
- // an already-fully-transformed `Transformed` (its `steps`/`pending`
126
- // inputs were recursed into by the CALLER before this runs), and over a
127
- // small fixed-shape `while` popping trailing `Predicate` pseudo-steps;
128
- // calls only check_focus_bind_target/apply_marker_to_step, never
129
- // transform_node or itself.
130
- // - wrap_marker_as_path (:545) -- calls splice_marker_steps once; no
131
- // recursion, no self-loop.
132
- // - resolve_ancestry (:252) -- the pass's entry point: calls
133
- // transform_node exactly once, then substitute_labels exactly once.
134
- // Not itself part of either cycle (never re-entered from within the
135
- // tree walk it kicks off), so it doesn't need a depth CHECK, but Task 2
136
- // should initialize/reset each of the three counters above here (one
137
- // for cycle (1)+(shared edge into (3)), one for substitute_labels).
23
+ // 2. Dropping an abandoned deep subtree recurses in the Drop glue,
24
+ // independently of any depth counter. Every owning bail-out (an `Err`
25
+ // return that still holds a subtree) must dismantle it iteratively via
26
+ // `push_ast_node_children`, never let it fall out of scope. That function
27
+ // deliberately has no `_` wildcard arm so a new AST variant cannot
28
+ // silently opt out.
138
29
  //
139
- // Step 3 sanity check performed (throwaway test, not committed): a
140
- // 200,000-step flat dot-path (`a.a.a...a`) -- same N as the crashing
141
- // arithmetic chain, for a clean apples-to-apples Ok-vs-crash comparison --
142
- // parsed via the FULL `parser::parse()` entry point returns `Ok`
143
- // immediately (iteration in transform_path_steps's `for step in steps`
144
- // loop, not recursion), while the 200,000-term arithmetic chain (`1+1+1+
145
- // ...`) still SIGABRTs ("stack overflow") via the same full `parser::
146
- // parse()` entry point in the same run -- confirming the root cause
147
- // identified in the prior session is still live in current code, and that
148
- // it's specifically recursion-on-nesting-depth (transform_children's
149
- // Binary arm), not merely "large input," that triggers it.
30
+ // Functions are referenced by NAME throughout this file — line numbers rot.
150
31
 
151
32
  use crate::ast::{AstNode, BinaryOp, PathStep, Stage};
152
33
  use std::collections::HashMap;
@@ -388,11 +269,27 @@ fn push_ast_node_children(node: AstNode, stack: &mut Vec<AstNode>) {
388
269
  }
389
270
  }
390
271
  AstNode::FunctionApplication(inner) | AstNode::Predicate(inner) => stack.push(*inner),
391
- // Leaf nodes (String/Name/Number/Boolean/Null/Undefined/Placeholder/
392
- // Regex/Variable/ParentVariable/Wildcard/Descendant/Parent): no
393
- // nested AstNode -- whatever's left of `node` (a String, an f64,
394
- // ...) is dropped here for free, in O(1), as this match arm ends.
395
- _ => {}
272
+ // Leaf nodes: no nested AstNode -- whatever's left of `node` (a
273
+ // String, an f64, ...) is dropped here for free, in O(1), as this
274
+ // match arm ends. Listed EXPLICITLY (no `_` wildcard) on purpose:
275
+ // this function is the stack-overflow-on-drop guard for abandoned
276
+ // deep subtrees, and a future variant holding a `Box<AstNode>` that
277
+ // fell into a wildcard would silently reintroduce the recursive-drop
278
+ // crash. A new variant must fail compilation here and be classified.
279
+ AstNode::String(_)
280
+ | AstNode::Name(_)
281
+ | AstNode::Number(_)
282
+ | AstNode::Boolean(_)
283
+ | AstNode::Null
284
+ | AstNode::Undefined
285
+ | AstNode::Placeholder
286
+ | AstNode::Regex { .. }
287
+ | AstNode::Variable(_)
288
+ | AstNode::ParentVariable(_)
289
+ | AstNode::Wildcard
290
+ | AstNode::Descendant
291
+ | AstNode::KeepArray
292
+ | AstNode::Parent(_) => {}
396
293
  }
397
294
  }
398
295
 
@@ -9,21 +9,13 @@ use jsonata_core::evaluator::EvaluatorError;
9
9
  /// callers use it directly.
10
10
  pub fn format_evaluator_error(e: &EvaluatorError) -> String {
11
11
  let msg = e.message();
12
- if is_coded_error(msg) {
12
+ if e.code().is_some() {
13
13
  msg.to_string()
14
14
  } else {
15
15
  format!("error: {}", msg)
16
16
  }
17
17
  }
18
18
 
19
- fn is_coded_error(message: &str) -> bool {
20
- let bytes = message.as_bytes();
21
- bytes.len() >= 6
22
- && matches!(bytes[0], b'T' | b'D' | b'U' | b'S')
23
- && bytes[1..5].iter().all(u8::is_ascii_digit)
24
- && bytes[5] == b':'
25
- }
26
-
27
19
  #[cfg(test)]
28
20
  mod tests {
29
21
  use super::*;