Introduction
jlens evaluates JSONata expressions against JSON documents. You can use it to filter, query and transform payloads, and to express business rules whose outcome must be explained.
It ships in three forms that share a single engine:
| Distribution | Package | Use it for |
|---|---|---|
| Rust crate | jlens-core (+ jlens-schema) | Embedding the engine in Rust services |
| Command line | jlens binary | Scripts, pipelines, NDJSON filtering, debugging expressions |
| WebAssembly | jlens-wasm (npm package, ES module) | Browsers: evaluation, editors with autocomplete, audit viewers |
To try it without installing anything, open the web editor: it runs jlens in your browser, with completion, diagnostics, and the explanation and audit tree of every result.
What sets jlens apart from other JSONata implementations:
- Explainable evaluation. jlens can record an audit tree of how an expression reached its result: which comparisons passed or failed, which items a filter rejected, and which values were bound. You can attach business labels through ordinary JSONata comments (annotations), so the expression stays valid in any other JSONata implementation.
- Schema-driven assistance. jlens infers structural metadata from sample documents. It then uses that metadata to offer completion, diagnostics and hover information for expressions being written, in the browser and in editors (language server).
- Conformance first. The engine is checked against the official JSONata test suite (2.2.2) on every change, and against jsonata-js itself with generated expressions every week; see Conformance testing.
Status: jlens 0.16 implements all of JSONata 2.2 (1,688 of the 1,690 official test cases, with the other two documented), host functions, evaluation audits, schema-driven editor support with a language server, a command line for scripts and agents that checks and tests expressions, and the browser package. See the roadmap.
Installation
jlens comes in three forms that share one engine. Install the ones you need.
Command line
Each GitHub release has the jlens binary for
Linux (x86-64 and ARM64, static), macOS (Intel and Apple silicon) and Windows (x86-64), with a
SHA256SUMS file. Download the archive for your platform and put jlens on your PATH, or let
cargo binstall do it:
cargo binstall jlens-cli
From 0.6.0 on, each archive has a build attestation: a signed statement that the release workflow of this repository built it, from which commit. Check one with the GitHub CLI:
gh attestation verify jlens-0.16.0-x86_64-unknown-linux-musl.tar.gz --repo thkprado/jlens
Elsewhere, build it from source with Rust 1.88 or later and a C compiler:
cargo install jlens-cli # from crates.io
cargo install --git https://github.com/thkprado/jlens jlens-cli
cargo install --path crates/jlens-cli # from a checkout
$ jlens --version
jlens 0.16.0
On macOS and Linux, Homebrew installs the same binary from the tap
thkprado/homebrew-jlens (from 0.14.0 on), and
brew upgrade follows the releases:
brew install thkprado/jlens/jlens
On Windows, Scoop installs it from the bucket
thkprado/scoop-jlens (from 0.14.0 on), and
scoop update jlens follows the releases:
scoop bucket add jlens https://github.com/thkprado/scoop-jlens
scoop install jlens/jlens
In a JavaScript project, the same binary will come from npm, in the package jlens-cli. It is
not published yet: at the 0.14.0 release, npm held back the name of the Windows package, and
jlens-cli waits for it. Until then, use one of the channels above. Once published:
npx jlens-cli --version # run it without installing
npm install --save-dev jlens-cli # or add it to the project; npm scripts then run `jlens`
jlens-cli runs the release binary for your platform, which npm installs from one of its
optional dependencies, jlens-cli-<os>-<cpu>, with no install script. Each run starts Node.js
first, which adds some tens of milliseconds. An install with --omit=optional leaves the binary
out, and so does a platform without a release binary (Windows on ARM64, for one): jlens then
says which package it looked for and exits with code 2.
With Docker or Podman, the container image ghcr.io/thkprado/jlens (from 0.14.0 on) runs the
static Linux binary, for linux/amd64 and linux/arm64, with nothing else in the image. It is
tagged with each version (0.16.0), each minor version (0.16) and latest. Give it -i to
read standard input, or mount the current directory on /work to read files:
echo '{"a": [1, 2]}' | docker run --rm -i ghcr.io/thkprado/jlens eval '$sum(a)'
docker run --rm -v "$PWD:/work" ghcr.io/thkprado/jlens eval -f rule.jsonata -i order.json
The image runs as an unprivileged user (UID 65532): add --user "$(id -u):$(id -g)" for files
that only you can read. It has a build attestation too:
gh attestation verify oci://ghcr.io/thkprado/jlens:0.16.0 --repo thkprado/jlens
Install the command through one channel. With more than one, the first jlens on the PATH
runs, whatever its version; inside npm scripts and npx, the project’s own jlens-cli comes
first. To see which one runs:
jlens --version
which -a jlens # Linux and macOS: every jlens on the PATH, the one that runs first
where.exe jlens # Windows
The CLI guide describes every command.
Rust crates
[dependencies]
jlens-core = "0.15" # the language: parsing, evaluation, functions, auditing
jlens-schema = "0.15" # optional: schema inference and the language service
serde_json = "1"
jlens-core depends on serde_json, indexmap, jlens-regress (a fork of regress) and a
few small crates. The
optional tracing feature adds tracing spans for evaluations; see
the audit guide. The crate guide shows how to use
the API.
Browser and JavaScript
The WebAssembly package is the npm package jlens-wasm, an ES module:
npm install jlens-wasm
From 0.6.0 on, the package is published with
provenance: its page on npmjs.com
links it to the commit and the workflow run that built it, and npm audit signatures checks it
in a project that depends on it.
To build it from a checkout instead, you need:
- the
wasm32-unknown-unknownRust target; - the
wasm-bindgenCLI, at the version inCargo.lock.
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version <version in Cargo.lock> --locked
cargo xtask wasm # writes crates/jlens-wasm/pkg
The WebAssembly guide covers loading the module in browsers, bundlers and Node.js.
This book
The documentation is an mdBook:
cargo install mdbook
mdbook serve docs # http://localhost:3000
cargo xtask doc-check # runs the examples and checks the links
First steps
This tour uses the jlens command line and the files in fixtures/. They describe a store
order: a customer and a list of items, each with a type, a color, a unit price, a
quantity and a tax. Every command runs from the repository root.
{
"number": "SO-12345",
"customer": { "login": "alice.walker99", "name": "Acme Stores" },
"items": [
{ "code": "1", "color": "blue", "type": "apparel", "unitPrice": 10000.0, "quantity": 2, "tax": 1500.0 },
{ "code": "2", "color": "black", "type": "electronic", "unitPrice": 25000.0, "quantity": 1, "tax": 4000.0 },
{ "code": "3", "color": "white", "type": "electronic", "unitPrice": 12000.0, "quantity": 1, "tax": 3000.0 }
]
}
Query
A path selects values. Predicates in square brackets filter them:
$ jlens eval -i fixtures/online-store-order.json 'customer.name'
"Acme Stores"
$ jlens eval -i fixtures/online-store-order.json -c 'items[type = "electronic"].code'
["2","3"]
$ jlens eval -i fixtures/online-store-order.json '$sum(items.(unitPrice * quantity))'
57000
The language reference explains paths, operators and functions.
Write a rule
fixtures/shipping-rule.jsonata is a business rule. Variables hold intermediate totals, then a
condition chooses the outcome:
(
$total := $sum(items.(unitPrice * quantity));
$electronics := $sum(items[type = 'electronic'].(unitPrice * quantity));
$approved := (
/** @label "Authorized customer" @message "The customer login must be on the approved partners list." */
customer.login in ["alice.walker99", "bob.harrison42"]
and
/** @label "Minimum order grand total" @message "The order grand total must exceed 50,000.00." */
$total > 50000
and
/** @label "Electronics group" */
($electronics > $total * 0.30 + 1000)
);
$approved
? { "action": "FREE_SHIPPING", "code": "OK_200" }
: { "action": "REGULAR_FEE", "code": "SHIP_FEE_402", "delivery": "SLOW" }
)
(The fixture has a few more checks; this is the gist.)
$ jlens eval -f fixtures/shipping-rule.jsonata -i fixtures/online-store-order.json
{
"action": "FREE_SHIPPING",
"code": "OK_200"
}
Explain the result
The /** @label ... */ comments are annotations. They are ordinary comments to other JSONata
implementations, but jlens uses them to explain results:
$ jlens eval -f fixtures/shipping-rule.jsonata -i fixtures/online-store-order.json --explain
result: {"action":"FREE_SHIPPING","code":"OK_200"}
• $total := $sum(items.(unitPrice * quantity)) = 57000
• $electronics := $sum(items[type = 'electronic'].(unitPrice * quantity)) = 37000
• [type = 'electronic']: 2 of 3 items passed; rejected: {"code":"1","color":"blue",...}
✔ $approved := ( customer.login in [...] and ... ) = true
✔ Authorized customer — customer.login in [...] → true ("alice.walker99" in [...])
✔ Minimum order grand total — $total > 50000 → true (57000 > 50000)
✔ Electronics group — ... → true
✔ $electronics > $total * 0.30 + 1000 → true (37000 > 18100)
• $approved ? { ... } : { ... } → {"action":"FREE_SHIPPING","code":"OK_200"} [then]
(Output shortened.) --audit json prints the full audit tree for programs to read. The
audit guide explains audits, levels and annotations.
Get help while writing
jlens can infer a schema from sample documents. Using it, jlens check reports fields that do
not exist, and the language service completes field names:
$ jlens schema infer fixtures/online-store-order.json -o order.schema.json
inferred from 1 sample(s): order.schema.json
$ jlens check --schema order.schema.json 'items[type = "electronic"].unitprice'
warning[W0201]: Field "unitprice" is not in the schema; did you mean `unitPrice`?
--> <expr>:1:28
|
1 | items[type = "electronic"].unitprice
| ^^^^^^^^^
$ jlens schema complete --schema order.schema.json "items[type = 'electronic']."
code field string
color field string
type field string
unitPrice field number
quantity field integer
tax field number
The same service drives editors in the browser; try the examples/web-editor demo
(WebAssembly guide).
Next
- Embed the engine in a Rust program: Rust crate.
- Use it in a browser: WebAssembly.
- Process files and streams: Command-line interface.
Language overview
JSONata is a query and transformation language for JSON. An expression selects values from a
document with location paths (Account.Order.Product), combines them with operators and
functions ($sum(Product.(Price * Quantity))), and shapes the output with ordinary JSON syntax
({"total": ...}). It also has variables, functions and conditionals, so rules of any
complexity can be written in it.
jlens implements JSONata 2.2 and is checked against the official test suite. Where it differs from the reference implementation, jsonata-js, Compatibility says so.
This reference is adapted from the JSONata documentation (© IBM Corp. and JSONata contributors, MIT license), reorganized and extended with jlens-specific notes. Every example in it is run against jlens by
cargo xtask doc-check.
Reading the examples
Examples show an expression followed by its result after =>:
1 + 2
=> 3
When an example needs an input document, its first line names it. Most examples use one of three sample documents, listed at the end of this page:
input: address.json
Address.City
=> "Winchester"
A result of undefined means the expression produced nothing: JSONata’s empty sequence. The
command line prints nothing for it, and the APIs return None or undefined.
Types
JSONata values have the JSON types plus one more:
| Type | Examples |
|---|---|
| string | "hello", 'hello' |
| number | 42, -3.5, 1e6 (always a 64-bit float) |
| Boolean | true, false |
| null | null |
| array | [1, "two", null] |
| object | {"name": "Fred", "age": 28} |
| function | $uppercase, function($x) { $x * 2 } |
JSONata is a superset of JSON. Every JSON document is a valid expression that evaluates to itself. A JSON template is therefore a good start for a transformation: replace its values with expressions.
input: address.json
{"name": FirstName & " " & Surname, "adult": Age >= 18, "phones": $count(Phone)}
=> {"name": "Fred Smith", "adult": true, "phones": 4}
Sequences
A path can match zero, one or many values, so JSONata expressions produce sequences. The rules for sequences shape every result:
- An empty sequence is nothing. It is not
null, and it does not appear in the output: an object field whose value is an empty sequence is left out. - A sequence of one value is that value. No array surrounds it.
- A sequence of several values is output as an array.
- Sequences are flattened. Values of nested sequences are pulled up into the outer one.
Arrays from the input, or built with
[...], keep their structure.
input: address.json
Phone[type = 'mobile'].number
=> "077 7700 1234"
input: address.json
Phone[type = 'office'].number
=> ["01962 001234", "01962 001235"]
input: address.json
{"fax": Phone[type = 'fax'].number, "name": FirstName}
=> {"name": "Fred"}
The second rule makes a single value and a one-element array interchangeable, which keeps
expressions composable. When a consistent array is needed, the
[] modifier keeps singleton results as arrays.
Comments
/* ... */ comments can appear anywhere whitespace can:
input: address.json
/* the person's full name */
FirstName & " " & Surname
=> "Fred Smith"
Comments that start with /** are documentation comments. jlens reads @label, @message
and @id tags in them to describe checks in audits; see
Audit annotations. Other JSONata implementations treat them as ordinary
comments.
The rest of this reference
- Paths: navigating objects and arrays, predicates, wildcards, and the path
stages (
#,@,%). - Building results: array and object constructors.
- Operators: arithmetic, comparison, Boolean, string, conditional and chaining operators, and their precedence.
- Sorting, grouping and aggregation.
- Programming: variables, blocks, functions, recursion, closures, partial application, function composition and the transform operator.
- Regular expressions and Dates and times.
- The function library, with a page per category.
- Errors: every error code.
Sample documents
address.json:
{
"FirstName": "Fred",
"Surname": "Smith",
"Age": 28,
"Address": {
"Street": "Hursley Park",
"City": "Winchester",
"Postcode": "SO21 2JN"
},
"Phone": [
{ "type": "home", "number": "0203 544 1234" },
{ "type": "office", "number": "01962 001234" },
{ "type": "office", "number": "01962 001235" },
{ "type": "mobile", "number": "077 7700 1234" }
],
"Email": [
{
"type": "work",
"address": ["fred.smith@my-work.com", "fsmith@my-work.com"]
},
{
"type": "home",
"address": ["freddy@my-social.com", "frederic.smith@very-serious.com"]
}
],
"Other": {
"Over 18 ?": true,
"Misc": null,
"Alternative.Address": {
"Street": "Brick Lane",
"City": "London",
"Postcode": "E1 6RF"
}
}
}
invoice.json:
{
"Account": {
"Account Name": "Firefly",
"Order": [
{
"OrderID": "order103",
"Product": [
{
"Product Name": "Bowler Hat",
"ProductID": 858383,
"SKU": "0406654608",
"Description": {
"Colour": "Purple",
"Width": 300,
"Height": 200,
"Depth": 210,
"Weight": 0.75
},
"Price": 34.45,
"Quantity": 2
},
{
"Product Name": "Trilby hat",
"ProductID": 858236,
"SKU": "0406634348",
"Description": {
"Colour": "Orange",
"Width": 300,
"Height": 200,
"Depth": 210,
"Weight": 0.6
},
"Price": 21.67,
"Quantity": 1
}
]
},
{
"OrderID": "order104",
"Product": [
{
"Product Name": "Bowler Hat",
"ProductID": 858383,
"SKU": "040657863",
"Description": {
"Colour": "Purple",
"Width": 300,
"Height": 200,
"Depth": 210,
"Weight": 0.75
},
"Price": 34.45,
"Quantity": 4
},
{
"ProductID": 345664,
"SKU": "0406654603",
"Product Name": "Cloak",
"Description": {
"Colour": "Black",
"Width": 30,
"Height": 20,
"Depth": 210,
"Weight": 2
},
"Price": 107.99,
"Quantity": 1
}
]
}
]
}
}
library.json:
{
"library": {
"books": [
{
"title": "Structure and Interpretation of Computer Programs",
"authors": ["Abelson", "Sussman"],
"isbn": "9780262510875",
"price": 38.90,
"copies": 2
},
{
"title": "The C Programming Language",
"authors": ["Kernighan", "Richie"],
"isbn": "9780131103627",
"price": 33.59,
"copies": 3
},
{
"title": "The AWK Programming Language",
"authors": ["Aho", "Kernighan", "Weinberger"],
"isbn": "9780201079814",
"copies": 1
},
{
"title": "Compilers: Principles, Techniques, and Tools",
"authors": ["Aho", "Lam", "Sethi", "Ullman"],
"isbn": "9780201100884",
"price": 23.38,
"copies": 1
}
],
"loans": [
{
"customer": "10001",
"isbn": "9780262510875",
"return": "2016-12-05"
},
{
"customer": "10003",
"isbn": "9780201100884",
"return": "2016-10-22"
},
{
"customer": "10003",
"isbn": "9780262510875",
"return": "2016-12-22"
}
],
"customers": [
{
"id": "10001",
"name": "Joe Doe",
"address": {
"street": "2 Long Road",
"city": "Winchester",
"postcode": "SO22 5PU"
}
},
{
"id": "10002",
"name": "Fred Bloggs",
"address": {
"street": "56 Letsby Avenue",
"city": "Winchester",
"postcode": "SO22 4WD"
}
},
{
"id": "10003",
"name": "Jason Arthur",
"address": {
"street": "1 Preddy Gate",
"city": "Southampton",
"postcode": "SO14 0MG"
}
}
]
}
}
Paths
A location path selects values from the input document. Field names separated by .
navigate into nested objects, and every array along the way is searched item by item.
Navigating objects
input: address.json
Surname
=> "Smith"
input: address.json
Address.City
=> "Winchester"
A path that matches null returns it. A path that matches nothing returns nothing (an empty
sequence), never an error: missing data is normal.
input: address.json
Other.Misc
=> null
input: address.json
Other.Nothing
=> undefined
Field names that are not plain identifiers go between backquotes:
input: address.json
Other.`Over 18 ?`
=> true
Navigating arrays
When a step selects an array, the following steps apply to each of its items, and the results are collected:
input: address.json
Phone.number
=> ["0203 544 1234", "01962 001234", "01962 001235", "077 7700 1234"]
A number in square brackets selects an item by position, from 0. Negative positions count from the end, and non-integer positions are rounded down. A position past the end selects nothing.
input: address.json
Phone[0]
=> {"type": "home", "number": "0203 544 1234"}
input: address.json
Phone[-1].number
=> "077 7700 1234"
input: address.json
Phone[8]
=> undefined
An array of positions selects several items:
input: address.json
Phone[[0, 2]].number
=> ["0203 544 1234", "01962 001235"]
input: address.json
Phone[[0..1]].number
=> ["0203 544 1234", "01962 001234"]
The index applies to the step it follows, not to the whole path so far. Phone.number[0]
takes the first number of each phone. Parentheses apply the index to the whole result:
input: address.json
Phone.number[0]
=> ["0203 544 1234", "01962 001234", "01962 001235", "077 7700 1234"]
input: address.json
(Phone.number)[0]
=> "0203 544 1234"
Predicates
Square brackets around any other expression filter the items: an item is kept when the
expression, evaluated with the item as context, is true. Values that are not Booleans are
converted as by $boolean.
input: address.json
Phone[type = 'mobile'].number
=> "077 7700 1234"
input: invoice.json
Account.Order.Product[Price > 30 and Quantity > 1].`Product Name`
=> ["Bowler Hat", "Bowler Hat"]
input: library.json
library.books["Aho" in authors].title
=> ["The AWK Programming Language", "Compilers: Principles, Techniques, and Tools"]
Always getting an array
Because a single value and a one-item sequence are the same (see
Sequences), a path can return a value or an array depending on the
data. Empty square brackets [] on any step keep the result an array:
input: address.json
Address[].City
=> ["Winchester"]
input: address.json
Phone[type = 'home'].number[]
=> ["0203 544 1234"]
The context: $ and $$
$ is the context value: the item a step is evaluated against. At the start of an expression
it is the whole input document, which is how a top-level array is indexed. $$ is always the
root of the input, wherever the evaluation is.
input: [{"ref": [1, 2]}, {"ref": [3, 4]}]
$[0].ref
=> [1, 2]
input: [{"ref": [1, 2]}, {"ref": [3, 4]}]
$.ref
=> [1, 2, 3, 4]
The nested arrays of the last example were flattened into one sequence. To keep the structure,
use an array constructor: $.[ref].
input: address.json
Phone[type = 'mobile'].($$.FirstName & ": " & number)
=> "Fred: 077 7700 1234"
Wildcards
* selects the values of all the fields of an object:
input: address.json
Address.*
=> ["Hursley Park", "Winchester", "SO21 2JN"]
input: address.json
*.Postcode
=> "SO21 2JN"
** selects descendants at any depth:
input: address.json
**.Postcode
=> ["SO21 2JN", "E1 6RF"]
Path stages
A path is a pipeline of stages. Each stage takes the sequence from the previous one:
| Stage | Syntax | Action |
|---|---|---|
| Map | seq . expr | Evaluates expr for each item, and flattens the results |
| Filter | seq [ expr ] | Keeps the items for which expr is true (or the positions expr gives) |
| Sort | seq ^( keys ) | Sorts the items; see Sorting |
| Index | seq # $var | Binds the position of each item to $var |
| Join | seq @ $var | Binds each item to $var, keeping the previous context |
| Group | seq { key: value, … } | Groups the items into one object; see Grouping |
A filter binds more tightly than a map, so books.authors[0] is the first author of each book.
Parentheses change the grouping, as (books.authors)[0] does. Parentheses also end the scope of
variables bound inside them.
Mapping with expressions
Any expression can be a step, not just a field name. Its context is each item in turn:
input: invoice.json
Account.Order.Product.(Price * Quantity)
=> [68.9, 21.67, 137.8, 107.99]
input: invoice.json
Account.Order.OrderID.$uppercase()
=> ["ORDER103", "ORDER104"]
Positions: #
#$i binds the position of each item, counted before later filters, to $i:
input: library.json
library.books#$i["Kernighan" in authors].{"title": title, "index": $i}
=> [
{"title": "The C Programming Language", "index": 1},
{"title": "The AWK Programming Language", "index": 2}
]
Joins: @
@$v binds each item to $v and keeps the context where it was, so the next step navigates
from the same place. That cross-references arrays like a join in a database:
input: library.json
library.loans@$l.books@$b[$l.isbn = $b.isbn].{"title": $b.title, "customer": $l.customer}
=> [
{"title": "Structure and Interpretation of Computer Programs", "customer": "10001"},
{"title": "Compilers: Principles, Techniques, and Tools", "customer": "10003"},
{"title": "Structure and Interpretation of Computer Programs", "customer": "10003"}
]
Parents: %
% selects the parent of the context value: the object whose field holds it. %.% is the
grandparent. The parent is worked out from the path when the expression is compiled, so %
only works in paths that reach the value through its parent (otherwise: error S0217).
input: invoice.json
Account.Order.Product.{"product": `Product Name`, "order": %.OrderID, "account": %.%.`Account Name`}
=> [
{"product": "Bowler Hat", "order": "order103", "account": "Firefly"},
{"product": "Trilby hat", "order": "order103", "account": "Firefly"},
{"product": "Bowler Hat", "order": "order104", "account": "Firefly"},
{"product": "Cloak", "order": "order104", "account": "Firefly"}
]
Building results
The output of an expression can have any shape. Array and object constructors use JSON syntax, with expressions wherever values (and object keys) go.
Arrays
Square brackets build an array of the values of the expressions inside them:
input: address.json
[Address.City, Other.`Alternative.Address`.City]
=> ["Winchester", "London"]
Unlike a sequence, an array built with [...] keeps its structure: it is neither flattened nor
unwrapped when it has a single item. As a path step, it builds one array per context item:
input: address.json
Email.address
=> ["fred.smith@my-work.com", "fsmith@my-work.com", "freddy@my-social.com", "frederic.smith@very-serious.com"]
input: address.json
Email.[address]
=> [
["fred.smith@my-work.com", "fsmith@my-work.com"],
["freddy@my-social.com", "frederic.smith@very-serious.com"]
]
An array constructor can also select several paths at once, as a more precise alternative to wildcards:
input: address.json
[Address, Other.`Alternative.Address`].City
=> ["Winchester", "London"]
Ranges
Inside an array constructor, from..to produces the integers from from to to, both
included. Both ends must be integers (error T2003, T2004), and a range can have at most
10 million items (D2014).
[1..5]
=> [1, 2, 3, 4, 5]
[1..3, 7..9]
=> [1, 2, 3, 7, 8, 9]
[1..5].($ * $)
=> [1, 4, 9, 16, 25]
input: address.json
[1..$count(Phone)].("Phone " & $)
=> ["Phone 1", "Phone 2", "Phone 3", "Phone 4"]
Objects
Braces build an object from key: value pairs. Keys are expressions that must evaluate to
strings (error T1003); values are any expressions. Fields whose value is nothing are left
out.
input: address.json
{"name": FirstName, "city": Address.City, "fax": Phone[type = 'fax'].number}
=> {"name": "Fred", "city": "Winchester"}
Where the constructor appears changes what it does:
-
After
.it is a map step and builds one object per item:input: address.json Phone.{type: number} => [ {"home": "0203 544 1234"}, {"office": "01962 001234"}, {"office": "01962 001235"}, {"mobile": "077 7700 1234"} ] -
Directly after a path (no
.) it groups the items into a single object. Values for the same key are collected together; see Grouping.input: address.json Phone{type: number} => { "home": "0203 544 1234", "office": ["01962 001234", "01962 001235"], "mobile": "077 7700 1234" }With
[], every group is an array, whatever its size:input: address.json Phone{type: number[]} => { "home": ["0203 544 1234"], "office": ["01962 001234", "01962 001235"], "mobile": ["077 7700 1234"] }
JSON literals
Values of every JSON type can be written as they are in JSON: strings (with JSON escapes, and
either quote character), numbers, true, false, null, arrays and objects. Because JSONata is
a superset of JSON, a JSON document makes a good template for the output.
input: invoice.json
{
"account": Account.`Account Name`,
"orders": Account.Order.{
"id": OrderID,
"total": $round($sum(Product.(Price * Quantity)), 2),
"items": $count(Product)
},
"currency": "EUR"
}
=> {
"account": "Firefly",
"orders": [
{"id": "order103", "total": 90.57, "items": 2},
{"id": "order104", "total": 245.79, "items": 2}
],
"currency": "EUR"
}
Operators
Arithmetic
| Operator | Meaning |
|---|---|
+ | Addition |
- | Subtraction, and negation in prefix form (-x) |
* | Multiplication |
/ | Division |
% | Remainder, with the sign of the left operand |
Both operands must be numbers (error T2001, T2002), and a result that is not finite (such
as 1/0) is an error (D1001). If either operand is nothing, the result is nothing.
5 + 2 * 3
=> 11
5 / 2
=> 2.5
-7 % 3
=> -1
input: invoice.json
Account.Order[0].Product[0].(Price * Quantity)
=> 68.9
"5" + 1
=> error T2001
missing + 1
=> undefined
Numbers are 64-bit floats, as in JavaScript, and results are printed the way JavaScript prints them:
0.1 + 0.2
=> 0.30000000000000004
Comparison
| Operator | Meaning |
|---|---|
= | Equal: same type and value; arrays and objects are compared deeply |
!= | Not equal |
<, <=, >, >= | Order of numbers, or of strings (by Unicode code point) |
in | The left value is one of the values on the right |
= and != never fail: values of different types are simply not equal. The ordering operators
need two numbers or two strings (errors T2009, T2010). Comparing with nothing gives
false.
1 + 1 = 2
=> true
"1" = 1
=> false
{"a": [1, 2]} = {"a": [1, 2]}
=> true
"apple" < "banana"
=> true
"world" in ["hello", "world"]
=> true
"hello" in "hello"
=> true
input: address.json
"01962 001234" in Phone.number
=> true
Boolean
and and or convert their operands to Booleans as $boolean
does, and stop as soon as the result is known: the right side of false and x is never
evaluated. Negation is a function, $not.
input: library.json
library.books["Aho" in authors and price < 50].title
=> "Compilers: Principles, Techniques, and Tools"
The other book by Aho has no price, and comparing nothing with 50 is false.
0 or "yes"
=> true
$not(true and false)
=> true
Strings
& joins strings. It is the only operator that converts its operands: non-strings become
strings as by $string, and nothing becomes the empty string.
input: address.json
FirstName & " " & Surname
=> "Fred Smith"
5 & 0 & true & missing
=> "50true"
Conditionals
condition ? then : else evaluates then when the condition is true, and else otherwise.
The condition is converted to a Boolean. Without an else part, a false condition produces
nothing.
input: invoice.json
Account.Order.Product.(Price > 100 ? "premium" : "standard")
=> ["standard", "standard", "standard", "premium"]
false ? "yes"
=> undefined
Defaults
| Operator | Result |
|---|---|
a ?: b | a if it is true as a Boolean, else b. A fallback for missing or falsy values (null, false, 0, "") |
a ?? b | a if it exists (is not nothing), else b. A fallback only for missing values |
input: address.json
Other.Misc ?: "n/a"
=> "n/a"
input: address.json
Other.Misc ?? "n/a"
=> null
0 ?? 1
=> 0
Chaining: ~>
value ~> $f(args) calls $f(value, args). Chains read left to right instead of inside out:
input: invoice.json
Account.Order.Product.(Price * Quantity) ~> $sum() ~> $round(1)
=> 336.4
" Hello World " ~> $trim() ~> $uppercase()
=> "HELLO WORLD"
With functions on both sides, ~> composes them into a new function; see
Function composition. A
regular expression on the right tests a string, which is useful in filters.
Other operators
| Operator | Meaning | See |
|---|---|---|
:= | Binds a variable | Variables |
.. | Integer range, in array constructors | Ranges |
|location|update, delete| | Transform a copy of an object | Transforms |
., [...], ^(...), #, @, %, *, ** | Path stages | Paths |
Precedence
From the tightest binding to the loosest:
| Operators | Associativity |
|---|---|
Function call f(...), filter [...], #, @ | left |
. | left |
Grouping {...} after a path | left |
*, /, % | left |
+, -, & | left |
=, !=, <, <=, >, >=, in, ~>, ^(...), ?:, ?? | left |
and | left |
or | left |
? : | right |
:= | right |
Unary minus binds more tightly than any binary operator. Parentheses override precedence:
(5 + 3) * 4
=> 32
1 < 2 and 2 < 3 or false
=> true
Sorting, grouping and aggregation
Sorting
The order-by stage ^(...) sorts the items of a path. Sort keys are expressions evaluated
against each item, and they must give numbers or strings. Strings sort by Unicode code point. A
key prefixed with > sorts in descending order; <, the default, sorts in ascending order.
Further keys, separated by commas, break ties. The sort is stable.
input: invoice.json
Account.Order.Product^(Price).`Product Name`
=> ["Trilby hat", "Bowler Hat", "Bowler Hat", "Cloak"]
input: invoice.json
Account.Order.Product^(>Price, <Quantity).{"name": `Product Name`, "qty": Quantity}
=> [
{"name": "Cloak", "qty": 1},
{"name": "Bowler Hat", "qty": 2},
{"name": "Bowler Hat", "qty": 4},
{"name": "Trilby hat", "qty": 1}
]
input: invoice.json
Account.Order.Product^(Price * Quantity).(Price * Quantity)
=> [21.67, 68.9, 107.99, 137.8]
$sort sorts an array with a comparison function, for orders that
keys cannot express.
Grouping
An object constructor directly after a path (without .) groups the items: it evaluates the
key for each item, and items with the same key fall into the same group. Then it evaluates the
value once per group, with the group’s items as context. Aggregations therefore work per group:
input: invoice.json
Account.Order.Product{`Product Name`: Price}
=> {"Bowler Hat": [34.45, 34.45], "Trilby hat": 21.67, "Cloak": 107.99}
input: invoice.json
Account.Order.Product{`Product Name`: $round($sum($.(Price * Quantity)), 2)}
=> {"Bowler Hat": 206.7, "Trilby hat": 21.67, "Cloak": 107.99}
The value expression runs against the whole group. A path in it therefore collects values
from all the group’s items. To build one object per item, map over the group with $.:
input: invoice.json
Account.Order.Product{`Product Name`: {"price": Price, "qty": Quantity}}
=> {
"Bowler Hat": {"price": [34.45, 34.45], "qty": [2, 4]},
"Trilby hat": {"price": 21.67, "qty": 1},
"Cloak": {"price": 107.99, "qty": 1}
}
input: invoice.json
Account.Order.Product{`Product Name`: $.{"price": Price, "qty": Quantity}}
=> {
"Bowler Hat": [{"price": 34.45, "qty": 2}, {"price": 34.45, "qty": 4}],
"Trilby hat": {"price": 21.67, "qty": 1},
"Cloak": {"price": 107.99, "qty": 1}
}
Keys must be strings (error T1003). Several key/value pairs can group the same items in
different ways, and a group that evaluates to nothing is left out.
input: library.json
library.books{
"cheap": $count($[price < 30]),
"expensive": $count($[price >= 30]),
"unpriced": $count($[$not($exists(price))])
}
=> {"cheap": 1, "expensive": 2, "unpriced": 1}
Aggregation
Aggregation functions reduce an array to one value:
input: invoice.json
$sum(Account.Order.Product.(Price * Quantity))
=> 336.36
input: invoice.json
{
"orders": $count(Account.Order),
"dearest": $max(Account.Order.Product.Price),
"average quantity": $average(Account.Order.Product.Quantity)
}
=> {"orders": 2, "dearest": 107.99, "average quantity": 2}
input: invoice.json
$join(Account.Order.Product.`Product Name`, ", ")
=> "Bowler Hat, Trilby hat, Bowler Hat, Cloak"
Programming
JSONata is a functional language. Values are immutable, functions are values, and everything is an expression.
Blocks
Parentheses group expressions separated by ;. The expressions are evaluated in order, and
the block’s value is the value of the last one. A block is also a scope for variables.
(1 + 2; "ignored"; 10 * 2)
=> 20
Variables
A name that starts with $ is a variable. $name := value binds a variable, and its value is
the bound value. The binding is visible in the rest of the block and in nested blocks, but not
outside the block.
input: invoice.json
Account.Order.Product.(
$total := Price * Quantity;
$tax := $total * 0.2;
$round($total + $tax, 2)
)
=> [82.68, 26, 165.36, 129.59]
($x := 1; ($x := 2); $x)
=> 1
Predefined variables:
$is the context value; see Paths.$$is the root of the input.- The built-in functions (
$sum,$string, …) are variables too, so a binding can replace one in its scope.
Applications can define more variables when they evaluate an expression: bindings, such as
jlens eval --bindings, Bindings in Rust, or the bindings argument in JavaScript.
bindings: {"limit": 100, "rate": 0.2}
$limit * (1 + $rate)
=> 120
Functions
Calling functions
A function is called with its arguments in parentheses:
$substring("hello world", 0, 5)
=> "hello"
Many built-in functions use the context value when their first argument is omitted (their
signature marks that argument with -). That makes them usable as path steps:
input: invoice.json
Account.Order.OrderID.$uppercase()
=> ["ORDER103", "ORDER104"]
Defining functions
function($a, $b) { body } defines a function. λ can be written instead of function.
Functions are values: bind them to variables, pass them to other functions, return them.
(
$volume := function($l, $w, $h) { $l * $w * $h };
$volume(10, 10, 5)
)
=> 500
λ($x) { $x * 2 }(21)
=> 42
Missing arguments are undefined inside the function, and extra arguments are ignored.
Signatures
An optional signature after the parameter list declares the types of the arguments and of the
result. Arguments are then checked when the function is called (errors T0410 and T0412).
(
$repeat := function($s, $n)<sn:s> { $join([1..$n].($s)) };
$repeat("ab", 3)
)
=> "ababab"
(
$repeat := function($s, $n)<sn:s> { $join([1..$n].($s)) };
$repeat(3, "ab")
)
=> error T0410
A signature is <params:result>, with one symbol per parameter:
| Symbol | Type |
|---|---|
b | Boolean |
n | number |
s | string |
l | null |
a | array; a<n> is an array of numbers |
o | object |
f | function |
(sn) | a choice: string or number |
u | (bnsl): any simple value |
j | (bnsloa): any JSON value |
x | any value, functions included |
These modifiers can follow a symbol:
?makes the parameter optional.+takes one or more arguments of the type.-takes the context value when the argument is missing.
A single value is accepted where an array is expected. For example, $sum has the signature
<a<n>:n> and $length has <s-:n>. The function reference gives the
signature of every built-in function.
Recursion and tail calls
A function bound to a variable can call itself through it:
(
$factorial := function($n) { $n <= 1 ? 1 : $n * $factorial($n - 1) };
$factorial(10)
)
=> 3628800
Each nested call uses stack, and evaluation stops with D1011 past a depth limit (see
Guardrails). A call in tail position, the last
thing a function does, does not nest. jlens runs it as a loop, so tail-recursive functions can
iterate any number of times:
(
$sumTo := function($n, $acc) { $n = 0 ? $acc : $sumTo($n - 1, $acc + $n) };
$sumTo(100000, 0)
)
=> 5000050000
Closures
A function remembers the variables and the context value of the place where it was defined:
(
$multiplier := function($k) { function($x) { $x * $k } };
$triple := $multiplier(3);
[$triple(2), $triple(5)]
)
=> [6, 15]
input: invoice.json
Account.(
$account := function() { $.`Account Name` };
Order[OrderID = 'order104'].Product.{"account": $account(), "product": `Product Name`}
)
=> [
{"account": "Firefly", "product": "Bowler Hat"},
{"account": "Firefly", "product": "Cloak"}
]
Higher-order functions
Functions that take or return functions are called higher-order. The library has several:
$map, $filter,
$reduce, $sift,
$single and $sort.
(
$twice := function($f) { function($x) { $f($f($x)) } };
$twice(function($y) { $y + 3 })(7)
)
=> 13
Partial application
Calling a function with ? in place of some arguments returns a new function that takes the
missing ones:
(
$first5 := $substring(?, 0, 5);
$first5("Hello, World")
)
=> "Hello"
(
$firstN := $substring(?, 0, ?);
$firstN("Hello, World", 3)
)
=> "Hel"
Function composition
When both sides of ~> are functions, the result is their composition: a function that applies
the left one, then the right one.
(
$normalize := $trim ~> $uppercase;
$normalize(" Some Words ")
)
=> "SOME WORDS"
input: address.json
(
$first5Capitalized := $substring(?, 0, 5) ~> $uppercase(?);
$first5Capitalized(Address.City)
)
=> "WINCH"
Conditional logic
The conditional operator ? : and the
default operators ?: and ?? cover if/then/else. Nested
conditionals read like an else if chain:
input: invoice.json
Account.Order.Product.(
Price > 100 ? "premium" :
Price > 30 ? "standard" :
"budget"
)
=> ["standard", "budget", "standard", "premium"]
Transforms
The transform operator |location|update| or |location|update, delete| produces a function.
That function copies its argument and, in the copy, changes every object that location
matches:
updatemust evaluate to an object. Its fields are merged into each matched object, as$mergedoes.updateis evaluated against the matched object.delete(optional) names the fields to remove: a string or an array of strings.
The original value is not modified.
input: invoice.json
(
$copy := $ ~> |Account.Order.Product|{"Price": $round(Price * 1.2, 2)}, ["SKU", "Description"]|;
$copy.Account.Order[0].Product
)
=> [
{"Product Name": "Bowler Hat", "ProductID": 858383, "Price": 41.34, "Quantity": 2},
{"Product Name": "Trilby hat", "ProductID": 858236, "Price": 26, "Quantity": 1}
]
Updates see the original values. Here Total uses the price before the 20% increase:
{"Price": 10, "Quantity": 3} ~> |$|{"Price": Price * 1.2, "Total": Price * Quantity}|
=> {"Price": 12, "Quantity": 3, "Total": 30}
A transform is a function, so it can be bound to a variable and reused, or chained with other
transforms. An update that would make an object contain itself is an error (U1001) in jlens;
see Compatibility.
Comments and annotations
/* ... */ comments are ignored. jlens reads documentation comments (/** ... */) placed before
a subexpression as audit annotations: labels and messages that explain the
checks of a rule.
Regular expressions
A regular expression is written between slashes, optionally followed by flags:
/pattern/flags
| Flag | Meaning |
|---|---|
i | Ignore case |
m | Multi-line: ^ and $ match at line breaks |
The syntax and semantics are those of JavaScript regular expressions (ECMAScript). They include
character classes, quantifiers (greedy and lazy), groups, backreferences, lookahead and
lookbehind, and Unicode escapes. jlens uses the regress engine,
which follows the ECMAScript specification. An invalid pattern fails with S0302.
Like JavaScript’s, the engine backtracks, so some patterns take exponential time on inputs they
almost match: /^(a+)+$/ on thirty a followed by ! tries half a billion ways to split the
run. When the evaluation has a timeout or a cancellation token, a match stops with D1012 or
U1003 like any other step (ADR 0009). Set a timeout when
the expressions come from users.
Functions that take a regular expression
$containstests whether a string matches.$matchreturns the matches, with positions and groups.$replacereplaces matches, with references to groups.$splitsplits a string around matches.
input: address.json
Email.address[$contains($, /@my-work\./)]
=> ["fred.smith@my-work.com", "fsmith@my-work.com"]
$replace("2024-03-15", /(\d+)-(\d+)-(\d+)/, "$3/$2/$1")
=> "15/03/2024"
Matching in predicates
A regular expression is a function: applied to a string, it returns the first match, or
nothing. With the chain operator ~>, that makes a compact test in a filter:
input: invoice.json
Account.Order.Product[`Product Name` ~> /hat/i].`Product Name`
=> ["Bowler Hat", "Trilby hat", "Bowler Hat"]
Matcher functions
Called directly, a regular expression returns an object describing the first match:
match: the matched text;startandend: its positions;groups: the captured texts;next: a function that returns the next match, or nothing.
(
$matcher := /[a-z]*an[a-z]*/i;
$m := $matcher("A man, a plan, a canal, Panama!");
[$m.match, $m.start, $m.end, $m.next().match]
)
=> ["man", 2, 5, "plan"]
$contains, $match, $replace and $split only rely on this contract. Any function that
returns such objects can therefore stand in for a regular expression, for example a lambda
that implements a custom matching rule.
Positions in match objects are character offsets as JavaScript counts them, in UTF-16 code units. A character outside the Basic Multilingual Plane counts as two.
Adapted from the JSONata documentation (MIT license).
Dates and times
JSON has no date type. Timestamps are usually strings in ISO 8601
format ("2018-12-10T13:45:00.000Z") or numbers of milliseconds since the Unix epoch. JSONata
works with both:
| Function | Purpose |
|---|---|
$now() | The evaluation time, as ISO 8601 or formatted |
$millis() | The evaluation time, in milliseconds |
$fromMillis(ms, picture?, timezone?) | Milliseconds to a formatted timestamp |
$toMillis(timestamp, picture?) | A timestamp to milliseconds |
The evaluation time is captured once, when the evaluation starts. Every $now() and $millis()
in an expression returns that same value:
input: invoice.json
(
$start := $now();
$total := $sum(Account.Order.Product.(Price * Quantity));
$now() = $start
)
=> true
Durations are differences between millisecond values:
($toMillis("2018-12-10T18:00:00Z") - $toMillis("2018-12-10T09:30:00Z")) / 3600000
=> 8.5
Picture strings
Pictures describe other formats, both for formatting ($fromMillis, $now) and for parsing
($toMillis). The syntax is that of XPath
fn:format-dateTime.
Text is copied as is, and components in square brackets stand for parts of the timestamp:
| Component | Meaning | Default presentation |
|---|---|---|
[Y] | year | 1 |
[M] | month of the year | 1 |
[D] | day of the month | 1 |
[d] | day of the year | 1 |
[F] | day of the week | n (name) |
[W] | ISO week of the year | 1 |
[w] | week of the month | 1 |
[X], [x] | ISO week-numbering year, month of the week-numbering year | 1 |
[H] | hour, 0–23 | 1 |
[h] | hour, 1–12 | 1 |
[P] | am or pm | n |
[m] | minutes | 01 |
[s] | seconds | 01 |
[f] | fractions of a second | 1 |
[Z] | time zone offset, +01:00 | 01:01 |
[z] | time zone as GMT+01:00 | 01:01 |
A presentation modifier after the component letter chooses how it is written:
| Modifier | Output |
|---|---|
1, 01, 001 | decimal digits, padded to the width shown |
#1 | digits with no padding ([h#1]) |
N, n, Nn | the name, in upper, lower or title case (months, days, am/pm) |
w, W, Ww | the number in words |
I, i | Roman numerals |
o after the presentation | an ordinal: 1st, first |
,min-max | the minimum and maximum width ([MNn,3-3] gives Jan) |
Literal square brackets are written [[ and ]].
$fromMillis(1544450400000, "[D01]/[M01]/[Y0001] [H01]:[m01]")
=> "10/12/2018 14:00"
$fromMillis(1544450400000, "[FNn], [D1o] [MNn] [YI]")
=> "Monday, 10th December MMXVIII"
$fromMillis(1544450400000, "[MNn,3-3] [D], [h]:[m01] [P]")
=> "Dec 10, 2:00 pm"
$fromMillis(1544450400000, "[Y]-[M01]-[D01]T[H01]:[m01]:[s01][Z]", "+0530")
=> "2018-12-10T19:30:00+05:30"
A timezone that is not a number is an error:
differs: jsonata-js writes "NaN" in every component
$fromMillis(1544450400000, "[Y]", "CET")
=> error D3150
Parsing
$toMillis parses with the same pictures. Parts missing from the picture take default values:
the start of the year, the month or the day.
$toMillis("10/12/2018", "[D]/[M]/[Y]") ~> $fromMillis("[M]/[D]/[Y]")
=> "12/10/2018"
$toMillis("Monday, 10th December 2018", "[FNn], [D1o] [MNn] [Y]")
=> 1544400000000
$toMillis("2018-12-10 14:00 +01:00", "[Y]-[M]-[D] [H]:[m] [Z]") ~> $fromMillis()
=> "2018-12-10T13:00:00.000Z"
Adapted from the JSONata documentation (MIT license).
Function reference
jlens implements every built-in function of JSONata 2.2. Each function has a signature
that describes its parameters and result, for example <s-nn?:s>:
| Symbol | Meaning |
|---|---|
s n b l o | string, number, boolean, null, object |
a | array; a single value is wrapped in an array |
a<n> | array of numbers (any type can be given in angle brackets) |
f | function |
j | any JSON value; x any value |
(sf) | either of the types |
? | optional parameter |
+ | one or more |
- | when the argument is missing, the context value is used |
:s | the result type |
Arguments that do not match the signature raise T0410 (wrong type), T0411 (the context
value has the wrong type) or T0412 (an array item has the wrong type).
String functions
Details and examples: String functions.
| Function | Signature | Description |
|---|---|---|
$string($arg, $prettify) | <x-b?:s> | Casts a value to a string; objects and arrays become JSON |
$substring($str, $start, $length) | <s-nn?:s> | Characters from a start position, optionally limited to a length |
$substringBefore($str, $chars) | <s-s:s> | The part of a string before the first occurrence of a substring |
$substringAfter($str, $chars) | <s-s:s> | The part of a string after the first occurrence of a substring |
$lowercase($str) | <s-:s> | Converts a string to lower case |
$uppercase($str) | <s-:s> | Converts a string to upper case |
$length($str) | <s-:n> | Number of characters in a string |
$trim($str) | <s-:s> | Normalizes and trims whitespace |
$pad($str, $width, $char) | <s-ns?:s> | Pads a string to a width; positive pads on the right, negative on the left |
$match($str, $regex, $limit) | <s-f<s:o>n?:a<o>> | Matches a regular expression, returning the matches with their positions and groups |
$contains($str, $token) | <s-(sf):b> | Whether a string contains a substring or matches a regular expression |
$replace($str, $pattern, $replacement, $limit) | <s-(sf)(sf)n?:s> | Replaces occurrences of a substring or regular expression |
$split($str, $separator, $limit) | <s-(sf)n?:a<s>> | Splits a string by a separator or regular expression |
$join($strs, $separator) | <a<s>s?:s> | Joins an array of strings with an optional separator |
Numeric functions
Details and examples: Numeric functions.
| Function | Signature | Description |
|---|---|---|
$formatNumber($value, $picture, $options) | <n-so?:s> | Formats a number with an XPath picture string |
$formatBase($value, $radix) | <n-n?:s> | Formats a number in another base (2 to 36) |
$formatInteger($value, $picture) | <n-s:s> | Formats an integer with an XPath picture string (words, roman, letters, digits) |
$parseInteger($value, $picture) | <s-s:n> | Parses an integer written according to an XPath picture string |
$number($arg) | <(nsb)-:n> | Casts a value to a number |
$floor($arg) | <n-:n> | Rounds down |
$ceil($arg) | <n-:n> | Rounds up |
$round($arg, $precision) | <n-n?:n> | Rounds half to even, optionally to a number of decimal places |
$abs($arg) | <n-:n> | Absolute value |
$sqrt($arg) | <n-:n> | Square root |
$power($arg, $exp) | <n-n:n> | Raises a number to a power |
$random() | <:n> | A pseudo-random number between 0 (inclusive) and 1 (exclusive) |
Aggregation functions
Details and examples: Aggregation functions.
| Function | Signature | Description |
|---|---|---|
$sum($array) | <a<n>:n> | Sum of an array of numbers |
$count($array) | <a:n> | Number of items in an array |
$max($array) | <a<n>:n> | Largest number in an array |
$min($array) | <a<n>:n> | Smallest number in an array |
$average($array) | <a<n>:n> | Mean of an array of numbers |
Boolean functions
Details and examples: Boolean functions.
| Function | Signature | Description |
|---|---|---|
$boolean($arg) | <x-:b> | The effective boolean value of a value |
$not($arg) | <x-:b> | Logical negation of the effective boolean value |
$exists($arg) | <x:b> | Whether a value is defined |
Array functions
Details and examples: Array functions.
| Function | Signature | Description |
|---|---|---|
$zip() | <a+> | Combines arrays item by item into an array of arrays |
$append($arg1, $arg2) | <xx:a> | Concatenates two values into an array |
$reverse($arr) | <a:a> | Reverses an array |
$sort($arr, $comparator) | <af?:a> | Sorts an array, optionally with a comparator function |
$shuffle($arr) | <a:a> | Shuffles an array into a random order |
$distinct($arr) | <x:x> | Removes duplicate values from an array |
Object functions
Details and examples: Object functions.
| Function | Signature | Description |
|---|---|---|
$keys($arg) | <x-:a<s>> | The keys of an object, or the union of keys of an array of objects |
$lookup($input, $key) | <x-s:x> | The value of a key in an object, or in each object of an array |
$spread($arg) | <x-:a<o>> | Splits an object into an array of single-key objects |
$merge($arg) | <a<o>:o> | Merges an array of objects into one; later keys win |
$each($obj, $func) | <o-f:a> | Applies a function to each key/value pair of an object |
Higher-order functions
Details and examples: Higher-order functions.
| Function | Signature | Description |
|---|---|---|
$map($arr, $func) | <af> | Applies a function to each item of an array |
$filter($arr, $func) | <af> | The items of an array for which a predicate holds |
$single($arr, $func) | <af?> | The only item of an array matching a predicate; an error otherwise |
$reduce($sequence, $func, $init) | <afj?:j> | Folds an array into a single value with a function |
$sift($arg, $func) | <o-f?:o> | The key/value pairs of an object for which a predicate holds |
Date and time functions
Details and examples: Date and time functions.
| Function | Signature | Description |
|---|---|---|
$now($picture, $timezone) | <s?s?:s> | The evaluation’s timestamp as an ISO 8601 string, or formatted with a picture |
$millis() | <:n> | The evaluation’s timestamp in milliseconds since the epoch |
$fromMillis($millis, $picture, $timezone) | <n-s?s?:s> | Formats milliseconds since the epoch as a timestamp string |
$toMillis($timestamp, $picture) | <s-s?:n> | Parses a timestamp string into milliseconds since the epoch |
Encoding functions
Details and examples: Encoding functions.
| Function | Signature | Description |
|---|---|---|
$base64encode($str) | <s-:s> | Encodes a string as Base64 (each character taken as a byte) |
$base64decode($str) | <s-:s> | Decodes a Base64 string (each byte becomes a character) |
$encodeUrlComponent($str) | <s-:s> | Percent-encodes a URL component |
$encodeUrl($str) | <s-:s> | Percent-encodes a URL, leaving reserved characters |
$decodeUrlComponent($str) | <s-:s> | Decodes a percent-encoded URL component |
$decodeUrl($str) | <s-:s> | Decodes a percent-encoded URL, leaving reserved characters encoded |
Other functions
Details and examples: Other functions.
| Function | Signature | Description |
|---|---|---|
$error($message) | <s?:x> | Raises an error (D3137) with a message |
$assert($condition, $message) | <bs?:x> | Raises an error (D3141) when a condition is false |
$type($value) | <x:s> | The type of a value: null, number, string, boolean, array, object or function |
$eval($expr, $focus) | <sx?:x> | Parses and evaluates a JSONata expression given as a string |
$clone($arg) | <(oa)-:o> | A deep copy of a value |
String functions
Most string functions use the context value when their first argument is omitted, so they
work as path steps: Account.Order.OrderID.$uppercase(). Positions and lengths count
characters (Unicode code points), except where noted.
$string
$string(arg, prettify?) converts a value to a string:
- strings are returned unchanged;
- functions become the empty string;
- other values are written as JSON.
With prettify true, objects and arrays are indented. A number that is not finite is an error
(D3001).
$string(5)
=> "5"
[1..3].$string()
=> ["1", "2", "3"]
$string({"a": [1, true, null]})
=> "{\"a\":[1,true,null]}"
Numbers are written with at most 15 significant digits, as in jsonata-js:
$string(0.1 + 0.2)
=> "0.3"
$length
$length(str) returns the number of characters in str.
$length("Hello World")
=> 11
$length("𝄞é")
=> 2
$substring
$substring(str, start, length?) returns the characters of str from position start
(from 0), at most length of them. A negative start counts from the end.
$substring("Hello World", 3)
=> "lo World"
$substring("Hello World", 3, 5)
=> "lo Wo"
$substring("Hello World", -4, 2)
=> "or"
$substringBefore
$substringBefore(str, chars) returns the part of str before the first occurrence of
chars, or all of str if chars does not occur.
$substringBefore("Hello World", " ")
=> "Hello"
$substringAfter
$substringAfter(str, chars) returns the part of str after the first occurrence of chars,
or all of str if chars does not occur.
$substringAfter("Hello World", " ")
=> "World"
$uppercase
$uppercase(str) converts str to upper case.
$uppercase("Hello World")
=> "HELLO WORLD"
$lowercase
$lowercase(str) converts str to lower case.
$lowercase("Hello World")
=> "hello world"
$trim
$trim(str) normalizes whitespace in three steps:
- Tabs, carriage returns and line feeds become spaces.
- Runs of spaces become one space.
- Leading and trailing spaces are removed.
$trim(" Hello \n World ")
=> "Hello World"
$pad
$pad(str, width, char?) pads str to at least |width| characters with char (a space by
default). A positive width pads on the right, a negative one on the left.
$pad("foo", 5)
=> "foo "
$pad("foo", -5, "#")
=> "##foo"
$formatBase(35, 2) ~> $pad(-8, "0")
=> "00100011"
$contains
$contains(str, pattern) tests whether str contains pattern, which is either a string or a
regular expression.
$contains("abracadabra", "bra")
=> true
$contains("Hello World", /wo/i)
=> true
input: address.json
Phone[$contains(number, /^077/)].type
=> "mobile"
$split
$split(str, separator, limit?) splits str into an array of strings around separator,
which is a string or a regular expression. An empty string separator splits str into
characters. limit caps the number of parts.
$split("so many words", " ")
=> ["so", "many", "words"]
$split("so many words", " ", 2)
=> ["so", "many"]
$split("too much, punctuation. hard; to read", /[ ,.;]+/)
=> ["too", "much", "punctuation", "hard", "to", "read"]
$join
$join(strings, separator?) joins an array of strings, with separator (empty by default)
between them. Every item must be a string.
$join(["a", "b", "c"])
=> "abc"
$split("too much, punctuation. hard; to read", /[ ,.;]+/, 3) ~> $join(", ")
=> "too, much, punctuation"
$match
$match(str, pattern, limit?) returns the matches of a regular expression in str. Each
match is an object with three fields:
match: the matched text;index: its position;groups: the texts captured by the parenthesized groups.
limit caps the number of matches. A single match is returned as a single object, following
the sequence rules.
$match("ababbabbcc", /a(b+)/)
=> [
{"match": "ab", "index": 0, "groups": ["b"]},
{"match": "abb", "index": 2, "groups": ["bb"]},
{"match": "abb", "index": 5, "groups": ["bb"]}
]
$match("ababbabbcc", /a(b+)/, 1).groups
=> ["b"]
$replace
$replace(str, pattern, replacement, limit?) replaces the occurrences of pattern (a string or
a regular expression) in str. limit caps the number of replacements.
A string replacement with a regex pattern can refer to what was matched:
$0is the whole match;$1,$2, … are the captured groups (empty when there is no such group);$$is a literal$.
replacement can also be a function. It receives each match, as $match describes it, and
returns the replacement string.
$replace("John Smith and John Jones", "John", "Mr")
=> "Mr Smith and Mr Jones"
$replace("John Smith and John Jones", "John", "Mr", 1)
=> "Mr Smith and John Jones"
$replace("John Smith", /(\w+)\s(\w+)/, "$2, $1")
=> "Smith, John"
$replace("265USD", /([0-9]+)USD/, "$$$1")
=> "$265"
(
$celsius := function($m) { ($number($m.groups[0]) - 32) * 5 / 9 & "C" };
$replace("temperature = 68F today", /(\d+)F/, $celsius)
)
=> "temperature = 20C today"
Adapted from the JSONata documentation (MIT license). The functions that format numbers as strings are on the numeric functions page.
Numeric functions
Numbers are 64-bit floats. A function whose result would not be a finite number fails with
D3060 ($sqrt) or D3061 ($power).
$number
$number(arg) converts a value to a number:
| Argument | Result |
|---|---|
| a number | unchanged |
| a string in JSON number syntax | that number |
a string starting with 0x, 0o or 0b | a hexadecimal, octal or binary integer |
true, false | 1, 0 |
| anything else | error D3030 |
$number("5")
=> 5
$number("0x12")
=> 18
["1", "2.5", "1e3"].$number()
=> [1, 2.5, 1000]
$number("five")
=> error D3030
$abs
$abs(n) returns the absolute value of n.
$abs(-5)
=> 5
$floor
$floor(n) rounds n down to an integer.
[$floor(5.8), $floor(-5.3)]
=> [5, -6]
$ceil
$ceil(n) rounds n up to an integer.
[$ceil(5.3), $ceil(-5.3)]
=> [6, -5]
$round
$round(n, precision?) rounds n to precision decimal places (0 by default). A negative
precision rounds to tens, hundreds, and so on. Halves round to the even neighbour (banker’s
rounding), as in IEEE 754 and financial calculations.
[$round(123.456), $round(123.456, 2), $round(123.456, -1)]
=> [123, 123.46, 120]
[$round(11.5), $round(12.5), $round(125, -1)]
=> [12, 12, 120]
$power
$power(base, exponent) raises base to exponent.
[$power(2, 8), $power(2, -2), $power(2, 0.5)]
=> [256, 0.25, 1.4142135623730951]
$power(-1, 0.5)
=> error D3061
$sqrt
$sqrt(n) returns the square root of n, which must not be negative.
$sqrt(16)
=> 4
$random
$random() returns a pseudo-random number in [0, 1). It is not suitable for cryptography.
$random() < 1
=> true
$formatNumber
$formatNumber(n, picture, options?) formats a number with an XPath
fn:format-number picture
string:
| Picture character | Meaning |
|---|---|
0 | a mandatory digit |
# | an optional digit |
. | the decimal separator |
, | the grouping separator |
%, ‰ | multiply by 100 or 1,000 and show the sign |
e | the exponent separator (scientific notation) |
; | separates the picture for positive numbers from the one for negative numbers |
options replaces the characters used, for example {"decimal-separator": ",", "grouping-separator": "."}. The other options are zero-digit, percent, per-mille,
minus-sign, infinity, NaN, exponent-separator and pattern-separator.
$formatNumber(12345.6, "#,###.00")
=> "12,345.60"
$formatNumber(1234.5678, "00.000e0")
=> "12.346e2"
[$formatNumber(34.555, "#0.00;(#0.00)"), $formatNumber(-34.555, "#0.00;(#0.00)")]
=> ["34.56", "(34.56)"]
$formatNumber(0.14, "01%")
=> "14%"
$formatNumber(1234567.891, "#.##0,00", {"decimal-separator": ",", "grouping-separator": "."})
=> "1.234.567,89"
$formatBase
$formatBase(n, radix?) writes the integer part of n in base radix, from 2 to 36 (10 by
default).
[$formatBase(100, 2), $formatBase(2555, 16)]
=> ["1100100", "9fb"]
$formatInteger
$formatInteger(n, picture) writes an integer with an XPath
fn:format-integer picture:
| Picture | Output |
|---|---|
0, 001, #,##0 | decimal digits, with a minimum width and grouping separators |
w, W, Ww | words, in lower, upper or title case |
i, I | Roman numerals |
a, A | letters: a…z, aa… |
suffix ;o | ordinal: 1st, first |
$formatInteger(2789, "w")
=> "two thousand, seven hundred and eighty-nine"
[$formatInteger(1999, "I"), $formatInteger(28, "A"), $formatInteger(3, "w;o")]
=> ["MCMXCIX", "AB", "third"]
$formatInteger(1234567, "#,##0")
=> "1,234,567"
$parseInteger
$parseInteger(str, picture) reads an integer written as $formatInteger writes it.
$parseInteger("twelve thousand, four hundred and seventy-six", "w")
=> 12476
$parseInteger("12,345,678", "#,##0")
=> 12345678
Adapted from the JSONata documentation (MIT license).
Aggregation functions
Aggregation functions reduce an array to a single value. A single value counts as an array of
one item, and an empty array gives 0 for $sum and $count, and nothing for the others. The
items of $sum, $max, $min and $average must be numbers (error T0412).
$sum
$sum(array) adds the numbers of array.
$sum([5, 1, 3, 7, 4])
=> 20
input: invoice.json
$sum(Account.Order.Product.Quantity)
=> 8
$count
$count(array) returns the number of items. It accepts any values.
[$count([1, 2, 3, 1]), $count("hello"), $count([])]
=> [4, 1, 0]
input: address.json
$count(Phone[type = 'office'])
=> 2
$max
$max(array) returns the largest number.
$max([5, 1, 3, 7, 4])
=> 7
$min
$min(array) returns the smallest number.
$min([5, 1, 3, 7, 4])
=> 1
$min([])
=> undefined
$average
$average(array) returns the mean of the numbers.
$average([5, 1, 3, 7, 4])
=> 4
Adapted from the JSONata documentation (MIT license).
Boolean functions
$boolean
$boolean(arg) converts a value to a Boolean. The same rules apply wherever JSONata needs a
Boolean: in predicates, with and, or, ?: and in conditions.
| Argument | Result |
|---|---|
| a Boolean | unchanged |
| a string | false if empty |
| a number | false if 0 |
null | false |
| an array | true if any item converts to true |
| an object | false if empty |
| a function | false |
| nothing | nothing (false in conditions) |
[$boolean("0"), $boolean(0), $boolean([0, ""]), $boolean([0, 1]), $boolean({})]
=> [true, false, false, true, false]
$not
$not(arg) converts arg to a Boolean and negates it.
[$not(true), $not(""), $not([1])]
=> [false, true, false]
input: library.json
library.books[$not("Kernighan" in authors)].title
=> ["Structure and Interpretation of Computer Programs", "Compilers: Principles, Techniques, and Tools"]
$exists
$exists(arg) returns true if arg produces a value, even null or false, and false if
it produces nothing.
input: address.json
[$exists(Other.Misc), $exists(Other.Nothing), $exists(Phone[type = 'fax'])]
=> [true, false, false]
Adapted from the JSONata documentation (MIT license).
Array functions
A single value passed where an array is expected counts as an array of one item. Results that are arrays are proper arrays: they keep their structure even with one item.
$append
$append(array1, array2) returns the items of both arrays, in order. A value that is not an
array is appended as one item.
$append([1, 2, 3], [4, 5, 6])
=> [1, 2, 3, 4, 5, 6]
$append("Hello", "World")
=> ["Hello", "World"]
$reverse
$reverse(array) returns the items in reverse order.
[1..5] ~> $reverse()
=> [5, 4, 3, 2, 1]
$sort
$sort(array, comparator?) returns the items in order. Without a comparator, the items must be
all numbers or all strings (error D3070), and they sort in ascending order.
comparator($a, $b) returns true when $a must come after $b. The sort is stable: items
the comparator considers equal keep their order.
$sort([5, 1, 3, 7, 4])
=> [1, 3, 4, 5, 7]
input: invoice.json
$sort(Account.Order.Product, function($l, $r) {
$l.Description.Weight > $r.Description.Weight
}).`Product Name`
=> ["Trilby hat", "Bowler Hat", "Bowler Hat", "Cloak"]
For sorting by keys, the order-by stage ^(...) is shorter.
$distinct
$distinct(array) removes duplicates, keeping the first occurrence of each value. Values are
compared as by =, deeply for arrays and objects.
$distinct([1, 2, 3, 3, 4, 3, 5])
=> [1, 2, 3, 4, 5]
input: invoice.json
$distinct(Account.Order.Product.Description.Colour)
=> ["Purple", "Orange", "Black"]
$shuffle
$shuffle(array) returns the items in random order.
$count($shuffle([1..9]))
=> 9
$zip
$zip(array1, array2, ...) groups the items at each position of its arguments into arrays. The
result is as long as the shortest argument.
$zip([1, 2, 3], [4, 5, 6])
=> [[1, 4], [2, 5], [3, 6]]
$zip([1, 2, 3], [4, 5], [7, 8, 9])
=> [[1, 4, 7], [2, 5, 8]]
$count is described with the aggregation functions.
Adapted from the JSONata documentation (MIT license).
Object functions
Object fields keep the order in which they were inserted.
$keys
$keys(object) returns the field names of object. For an array of objects, it returns the
names of all their fields, without duplicates.
input: address.json
$keys(Address)
=> ["Street", "City", "Postcode"]
input: address.json
$keys(Phone)
=> ["type", "number"]
$lookup
$lookup(object, key) returns the value of field key, for names that are not known in
advance. For an array of objects, it returns the values from all of them.
input: address.json
$lookup(Address, "City")
=> "Winchester"
input: address.json
(
$field := "number";
$lookup(Phone[type = 'mobile'], $field)
)
=> "077 7700 1234"
$spread
$spread(object) splits an object into an array of objects with one field each. For an array of
objects, it splits each of them.
input: address.json
$spread(Address)
=> [{"Street": "Hursley Park"}, {"City": "Winchester"}, {"Postcode": "SO21 2JN"}]
$merge
$merge(array) merges an array of objects into one. When several objects have the same field,
the last one wins.
$merge([{"a": 1, "b": 2}, {"b": 3, "c": 4}])
=> {"a": 1, "b": 3, "c": 4}
input: address.json
$merge($spread(Address)[$keys($) != "Postcode"])
=> {"Street": "Hursley Park", "City": "Winchester"}
$each
$each(object, function) calls function($value, $key) for each field and returns the results.
input: address.json
$each(Address, function($v, $k) { $k & ": " & $v })
=> ["Street: Hursley Park", "City: Winchester", "Postcode: SO21 2JN"]
$sift, which keeps the fields that satisfy a function, is described with the
higher-order functions.
Adapted from the JSONata documentation (MIT license).
Higher-order functions
These functions take a function as an argument. The function receives an item and, if it
declares more parameters, the item’s position and the whole array (or, for $sift, the field
name and the whole object).
$map
$map(array, function) returns the results of function($value, $index, $array) for each item.
Results follow the sequence rules: one result is returned as
itself, and none as nothing.
$map([1, 2, 3], function($v) { $v * 2 })
=> [2, 4, 6]
$map([1..5], $string)
=> ["1", "2", "3", "4", "5"]
input: address.json
$map(Email.address, function($v, $i, $a) { "Item " & ($i + 1) & " of " & $count($a) & ": " & $v })
=> [
"Item 1 of 4: fred.smith@my-work.com",
"Item 2 of 4: fsmith@my-work.com",
"Item 3 of 4: freddy@my-social.com",
"Item 4 of 4: frederic.smith@very-serious.com"
]
$filter
$filter(array, function) keeps the items for which function($value, $index, $array) is true.
input: invoice.json
$filter(Account.Order.Product, function($v, $i, $a) { $v.Price > $average($a.Price) }).`Product Name`
=> "Cloak"
$single
$single(array, function?) returns the only item for which function is true. More than one
match is an error (D3138), and so is none (D3139). Without a function, the array must have
exactly one item.
input: invoice.json
$single(Account.Order.Product, function($v) { $v.SKU = "0406654608" }).`Product Name`
=> "Bowler Hat"
input: invoice.json
$single(Account.Order.Product, function($v) { $v.Price > 30 })
=> error D3138
$reduce
$reduce(array, function, init?) folds the array into one value. It calls
function($accumulator, $value, $index, $array) for each item, carrying the result to the next
call. Without init, the first item is the initial accumulator.
$reduce([1..5], function($acc, $v) { $acc * $v })
=> 120
input: invoice.json
$reduce(Account.Order.Product, function($acc, $p) { $acc + $p.Quantity }, 0)
=> 8
$sift
$sift(object, function) keeps the fields of object for which function($value, $key, $object) is true. With one argument it applies to the context value.
input: invoice.json
(Account.Order.Product)[0].$sift(function($v, $k) { $k ~> /^Product/ })
=> {"Product Name": "Bowler Hat", "ProductID": 858383}
$sift({"a": 1, "b": null, "c": 0}, function($v) { $v != null })
=> {"a": 1, "c": 0}
$sort takes a comparison function too; see array functions.
Adapted from the JSONata documentation (MIT license).
Date and time functions
JSON has no date type, so timestamps are strings, usually in ISO 8601 format, or numbers of milliseconds since the Unix epoch (1970-01-01T00:00:00Z). These functions convert between the two, with picture strings for other formats. Dates and times describes the picture string syntax.
$now
$now(picture?, timezone?) returns the time the evaluation started, as an ISO 8601 string in
UTC, or formatted as $fromMillis would format it. Every call in one evaluation returns the
same time.
$now() = $now()
=> true
$now() ~> $contains(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/)
=> true
$millis
$millis() returns the time the evaluation started, in milliseconds since the epoch. Like
$now(), it is the same throughout one evaluation.
$millis() = $toMillis($now())
=> true
$fromMillis
$fromMillis(millis, picture?, timezone?) formats a number of milliseconds as a timestamp. It
uses ISO 8601 without a picture, and an XPath
fn:format-dateTime picture
string otherwise. timezone, written ±HHMM, shifts the time from UTC.
$fromMillis(1510067557121)
=> "2017-11-07T15:12:37.121Z"
$fromMillis(1510067557121, "[M01]/[D01]/[Y0001] [h#1]:[m01][P]")
=> "11/07/2017 3:12pm"
$fromMillis(1510067557121, "[H01]:[m01]:[s01] [z]", "-0500")
=> "10:12:37 GMT-05:00"
$fromMillis(1510067557121, "[FNn], [D1o] [MNn] [Y]")
=> "Tuesday, 7th November 2017"
$toMillis
$toMillis(timestamp, picture?) parses a timestamp into milliseconds since the epoch. Without a
picture it expects ISO 8601. With one, it parses according to the picture. A timestamp that
does not match fails with D3110.
$toMillis("2017-11-07T15:07:54.972Z")
=> 1510067274972
$toMillis("10/12/2018", "[D]/[M]/[Y]") ~> $fromMillis("[Y]-[M01]-[D01]")
=> "2018-12-10"
A date and time without a time zone is read as UTC. jsonata-js reads it in the machine’s local time zone, so its results depend on where it runs; see Compatibility.
differs: jsonata-js reads the time in the local time zone
$toMillis("2018-12-10T10:00:00")
=> 1544436000000
Adapted from the JSONata documentation (MIT license).
Encoding functions
$base64encode
$base64encode(str) encodes a string in base 64. Each character is taken as one byte, so the
string must be in the Latin-1 range (\u0000 to ÿ). Encode other text with
$encodeUrlComponent first, if needed.
$base64encode("myuser:mypass")
=> "bXl1c2VyOm15cGFzcw=="
$base64decode
$base64decode(str) decodes base 64 into a string, reading the bytes as UTF-8.
$base64decode("bXl1c2VyOm15cGFzcw==")
=> "myuser:mypass"
$encodeUrlComponent
$encodeUrlComponent(str) percent-encodes the UTF-8 bytes of every character that is not
allowed in a URL component. It behaves like JavaScript’s encodeURIComponent.
$encodeUrlComponent("?x=test")
=> "%3Fx%3Dtest"
$encodeUrl
$encodeUrl(str) percent-encodes a whole URL, keeping the characters that have a meaning in
URLs (:/?#[]@!$&'()*+,;=). It behaves like JavaScript’s encodeURI.
$encodeUrl("https://mozilla.org/?x=шеллы")
=> "https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B"
$decodeUrlComponent
$decodeUrlComponent(str) reverses $encodeUrlComponent. Malformed escapes fail with D3140.
$decodeUrlComponent("%3Fx%3Dtest")
=> "?x=test"
$decodeUrl
$decodeUrl(str) reverses $encodeUrl.
$decodeUrl("https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B")
=> "https://mozilla.org/?x=шеллы"
Adapted from the JSONata documentation (MIT license).
Other functions
$type
$type(value) returns the type of value: "null", "number", "string", "boolean",
"array", "object" or "function". It returns nothing for nothing.
[$type(1), $type("1"), $type(null), $type([]), $type({}), $type($type)]
=> ["number", "string", "null", "array", "object", "function"]
$type(missing)
=> undefined
$error
$error(message?) stops the evaluation with error D3137 and the given message.
bindings: {"limit": -1}
$limit >= 0 ? $limit : $error("The limit must not be negative")
=> error D3137
$assert
$assert(condition, message?) stops the evaluation with error D3141 and the message when
condition is false. When it is true, it returns nothing.
input: address.json
($assert(Age >= 18, "Adults only"); FirstName)
=> "Fred"
input: address.json
($assert(Age >= 30, "Too young"); FirstName)
=> error D3141
$eval
$eval(expression, context?) parses and evaluates a JSONata expression given as a string. The
context value is context if given, the current context otherwise. A syntax error in
expression fails with D3120; an evaluation error, with D3121.
$eval("[1, $string(2), 3]")
=> [1, "2", 3]
input: address.json
$eval("FirstName & ' ' & Surname")
=> "Fred Smith"
$eval("a + b", {"a": 1, "b": 2})
=> 3
$clone
$clone(value) returns a deep copy of an object or array. Transforms
use it to copy their input.
$clone({"a": [1, {"b": 2}]})
=> {"a": [1, {"b": 2}]}
Adapted from the JSONata documentation (MIT license).
Error reference
Every error jlens reports carries a stable code. The codes and messages match those of
jsonata-js, so code that handles JSONata errors works unchanged. In a message template,
{{name}} is replaced with the JSON form of the error detail name, and {{{name}}}
with its raw text.
Errors expose:
code();message();position(), a UTF-16 offset just past the offending token, as in jsonata-js;span(), the byte range of the offending text;- details such as
token()andvalue().
Static errors (S)
Raised while compiling an expression.
| Code | Message |
|---|---|
S0101 | String literal must be terminated by a matching quote |
S0102 | Number out of range: {{token}} |
S0103 | Unsupported escape sequence: \{{token}} |
S0104 | The escape sequence \u must be followed by 4 hex digits |
S0105 | Quoted property name must be terminated with a backquote ()` |
S0106 | Comment has no closing tag |
S0201 | Syntax error: {{token}} |
S0202 | Expected {{value}}, got {{token}} |
S0203 | Expected {{value}} before end of expression |
S0204 | Unknown operator: {{token}} |
S0205 | Unexpected token: {{token}} |
S0206 | Unknown expression type: {{token}} |
S0207 | Unexpected end of expression |
S0208 | Parameter {{value}} of function definition must be a variable name (start with $) |
S0209 | A predicate cannot follow a grouping expression in a step |
S0210 | Each step can only have one grouping expression |
S0211 | The symbol {{token}} cannot be used as a unary operator |
S0212 | The left side of := must be a variable name (start with $) |
S0213 | The literal value {{value}} cannot be used as a step within a path expression |
S0214 | The right side of {{token}} must be a variable name (start with $) |
S0215 | A context variable binding must precede any predicates on a step |
S0216 | A context variable binding must precede the 'order-by' clause on a step |
S0217 | The object representing the 'parent' cannot be derived from this expression |
S0301 | Empty regular expressions are not allowed |
S0302 | No terminating / in regular expression |
S0402 | Choice groups containing parameterized types are not supported |
S0401 | Type parameters can only be applied to functions and arrays |
S0500 | Attempted to evaluate an expression containing syntax error(s) |
Type errors (T)
Raised while evaluating, when a value has the wrong type.
| Code | Message |
|---|---|
T0410 | Argument {{index}} of function {{token}} does not match function signature |
T0411 | Context value is not a compatible type with argument {{index}} of function {{token}} |
T0412 | Argument {{index}} of function {{token}} must be an array of {{type}} |
T1003 | Key in object structure must evaluate to a string; got: {{value}} |
T1005 | Attempted to invoke a non-function. Did you mean ${{{token}}}? |
T1006 | Attempted to invoke a non-function |
T1007 | Attempted to partially apply a non-function. Did you mean ${{{token}}}? |
T1008 | Attempted to partially apply a non-function |
T1010 | The matcher function argument passed to function {{token}} does not return the correct object structure |
T2001 | The left side of the {{token}} operator must evaluate to a number |
T2002 | The right side of the {{token}} operator must evaluate to a number |
T2003 | The left side of the range operator (..) must evaluate to an integer |
T2004 | The right side of the range operator (..) must evaluate to an integer |
T2006 | The right side of the function application operator ~> must be a function |
T2007 | Type mismatch when comparing values {{value}} and {{value2}} in order-by clause |
T2008 | The expressions within an order-by clause must evaluate to numeric or string values |
T2009 | The values {{value}} and {{value2}} either side of operator {{token}} must be of the same data type |
T2010 | The expressions either side of operator {{token}} must evaluate to numeric or string values |
T2011 | The insert/update clause of the transform expression must evaluate to an object: {{value}} |
T2012 | The delete clause of the transform expression must evaluate to a string or array of strings: {{value}} |
T2013 | The transform expression clones the input object using the $clone() function. This has been overridden in the current scope by a non-function. |
Dynamic errors (D)
Raised while evaluating, for other run-time problems.
| Code | Message |
|---|---|
D1001 | Number out of range: {{value}} |
D1002 | Cannot negate a non-numeric value: {{value}} |
D1004 | Regular expression matches zero length string |
D1009 | Multiple key definitions evaluate to same key: {{value}} |
D1011 | Stack overflow. Check for non-terminating recursive function. Consider rewriting as tail-recursive |
D1012 | Evaluation timeout after {{value}} milliseconds. Check for infinite loop |
D1013 | Object property names starting with _jsonata_ are reserved for internal use: {{value}} |
D2005 | The left side of := must be a variable name (start with $) |
D2014 | The size of the sequence allocated by the range operator (..) must not exceed 1e7. Attempted to allocate {{value}}. |
D2015 | The maximum sequence length of {{value}} was exceeded. |
D2016 | The size of the string requested by the $pad function must not exceed 1e7. Attempted to allocate {{value}}. |
D3001 | Attempting to invoke string function on Infinity or NaN |
D3010 | Second argument of replace function cannot be an empty string |
D3011 | Fourth argument of replace function must evaluate to a positive number |
D3012 | Attempted to replace a matched string with a non-string value |
D3020 | Third argument of split function must evaluate to a positive number |
D3030 | Unable to cast value to a number: {{value}} |
D3040 | Third argument of match function must evaluate to a positive number |
D3050 | The second argument of reduce function must be a function with at least two arguments |
D3060 | The sqrt function cannot be applied to a negative number: {{value}} |
D3061 | The power function has resulted in a value that cannot be represented as a JSON number: base={{value}}, exponent={{exp}} |
D3070 | The single argument form of the sort function can only be applied to an array of strings or an array of numbers. Use the second argument to specify a comparison function |
D3080 | The picture string must only contain a maximum of two sub-pictures |
D3081 | The sub-picture must not contain more than one instance of the 'decimal-separator' character |
D3082 | The sub-picture must not contain more than one instance of the 'percent' character |
D3083 | The sub-picture must not contain more than one instance of the 'per-mille' character |
D3084 | The sub-picture must not contain both a 'percent' and a 'per-mille' character |
D3085 | The mantissa part of a sub-picture must contain at least one character that is either an 'optional digit character' or a member of the 'decimal digit family' |
D3086 | The sub-picture must not contain a passive character that is preceded by an active character and that is followed by another active character |
D3087 | The sub-picture must not contain a 'grouping-separator' character that appears adjacent to a 'decimal-separator' character |
D3088 | The sub-picture must not contain a 'grouping-separator' at the end of the integer part |
D3089 | The sub-picture must not contain two adjacent instances of the 'grouping-separator' character |
D3090 | The integer part of the sub-picture must not contain a member of the 'decimal digit family' that is followed by an instance of the 'optional digit character' |
D3091 | The fractional part of the sub-picture must not contain an instance of the 'optional digit character' that is followed by a member of the 'decimal digit family' |
D3092 | A sub-picture that contains a 'percent' or 'per-mille' character must not contain a character treated as an 'exponent-separator' |
D3093 | The exponent part of the sub-picture must comprise only of one or more characters that are members of the 'decimal digit family' |
D3100 | The radix of the formatBase function must be between 2 and 36. It was given {{value}} |
D3110 | The argument of the toMillis function must be an ISO 8601 formatted timestamp. Given {{value}} |
D3120 | Syntax error in expression passed to function eval: {{value}} |
D3121 | Dynamic error evaluating the expression passed to function eval: {{value}} |
D3130 | Formatting or parsing an integer as a sequence starting with {{value}} is not supported by this implementation |
D3131 | In a decimal digit pattern, all digits must be from the same decimal group |
D3132 | Unknown component specifier {{value}} in date/time picture string |
D3133 | The 'name' modifier can only be applied to months and days in the date/time picture string, not {{value}} |
D3134 | The timezone integer format specifier cannot have more than four digits |
D3135 | No matching closing bracket ']' in date/time picture string |
D3136 | The date/time picture string is missing specifiers required to parse the timestamp |
D3137 | {{{message}}} |
D3138 | The $single() function expected exactly 1 matching result. Instead it matched more. |
D3139 | The $single() function expected exactly 1 matching result. Instead it matched 0. |
D3140 | Malformed URL passed to ${{{functionName}}}(): {{value}} |
D3141 | {{{message}}} |
D3150 | The timezone must be a number of hours and minutes, such as +0530: {{value}} |
Guardrail errors (U)
Raised when an evaluation exceeds its configured limits, is cancelled, or needs what jlens does not support. U2001 is raised by jlens eval --json for an input document that is not valid JSON.
| Code | Message |
|---|---|
U1001 | {{{message}}} |
U1002 | The function ${{{token}}} returned a promise: asynchronous functions are not supported |
U1003 | The evaluation was cancelled |
U1004 | The input could not be read: {{{message}}} |
U1005 | A function passed to ${{{token}}} was called after ${{{token}}} returned |
U2001 | The input is not valid JSON: {{{message}}} |
Warnings (W)
jlens-specific. Warnings are reported as diagnostics by jlens check and parse_recover, and never stop evaluation.
| Code | Message |
|---|---|
W0101 | This annotation is not attached to an expression and will be ignored |
W0102 | Unknown annotation tag @{{{tag}}}; expected @label, @message or @id |
W0103 | The value of annotation tag @{{{tag}}} should be a JSON string literal |
W0201 | Field {{token}} is not in the schema{{{hint}}} |
W0202 | Unknown function ${{{token}}}{{{hint}}} |
W0203 | Function ${{{token}}} takes {{{expected}}} but is given {{{count}}} |
W0204 | Variable ${{{token}}} is not declared{{{hint}}} |
W0205 | Argument {{{index}}} of ${{{token}}} is {{{type}}}, which does not match the signature {{{signature}}} |
Audit annotations
An annotation attaches business metadata to part of an expression: a label, a message and an identifier. jlens uses annotations to make audit trees and explanations readable. Annotations are written as JSONata comments, so an annotated expression is still valid JSONata and evaluates identically in any other implementation.
/** @label "Authorized customer" @message "The customer login must be on the approved list." */
customer.login in $approved
and
/** @label "Minimum total" @id "RULE-042" */
$sum(items.(unitPrice * quantity)) > 50000
Syntax
An annotation is a block comment that starts with /**. The empty comment /**/ and ordinary
/* ... */ comments are not annotations.
| Element | Meaning |
|---|---|
@label "…" | Short name shown in audit trees and explanations |
@message "…" | Longer explanation of what the expression checks |
@id "…" | Stable identifier, for referring to this check from other systems |
| text before the first tag | Free-form description |
Tag values are JSON string literals, so \", \n and é escapes work. Leading *
characters on continuation lines are ignored, so multi-line comments can be written JSDoc-style:
/**
* Customers must be registered partners.
* @label "Authorized customer"
* @id "IMP-001"
*/
customer.login in $approved
When several annotations precede the same expression, they are merged and later tags win.
What an annotation covers
An annotation covers the operand that follows it. It extends to the right as far as the
surrounding operators allow, but never past and or or, nor past operators that bind more
loosely: ? (the conditional), := and ... In practice:
| Expression | Annotated part |
|---|---|
/** A */ x.y in $list and z | x.y in $list |
a and /** A */ b > 1 or c | b > 1 |
/** A */ $total > 100 ? "big" : "small" | $total > 100 (the condition) |
a - /** A */ b + c | b (the operand of -) |
$f(/** A */ items.price, 2) | items.price (the argument) |
/** A */ (x or y) | the whole parenthesised block |
$x := /** A */ $sum(values) | $sum(values) (the bound value) |
Annotations never change how an expression is parsed. The syntax tree of an annotated expression is the same as the tree of the expression with its comments removed; only the annotation metadata is added to one node.
To label a whole and/or chain, wrap it in parentheses, as the rule in
fixtures/shipping-rule.jsonata does:
/** @label "Order category check" */
(
/** @label "Electronics group" */ ('electronic' in items.type and $electronics > $total * 0.3)
or
/** @label "Blue items group" */ ('blue' in items.color and $blueTax < $averageTax)
)
Warnings
Annotation problems are warnings: they are reported, but they never stop compilation or evaluation.
| Code | When |
|---|---|
W0101 | The annotation precedes something that is not an expression, such as an operator, a closing bracket, or the end of the input |
W0102 | Unknown tag, such as @severity |
W0103 | A tag value is not a quoted JSON string; the rest of the line is used as the value |
Run jlens check to see them:
$ jlens check '/** @label "Total" @severity "high" */ $sum(items.price) > 100'
warning[W0102]: Unknown annotation tag @severity; expected @label, @message or @id
--> <expr>:1:20
|
1 | /** @label "Total" @severity "high" */ $sum(items.price) > 100
| ^^^^^^^^^
<expr>: 0 error(s), 1 warning(s)
In the API
After parsing, the annotation is available on the annotated node as Node::annotation. It also
appears in the JSON form of the syntax tree (jlens ast):
{ "type": "binary", "op": "in", "annotation": { "label": "Authorized customer", "span": { … } } }
The audit guide explains how annotations appear in audit trees.
Compatibility with jsonata-js
jlens targets the behaviour of jsonata-js 2.2.2, as specified by its official test suite. It copies that behaviour, quirks included, unless copying would cost precision, correctness or performance: a result made inexact by JavaScript’s rounding, a result that is wrong by the JSONata documentation or exposes a JavaScript internal, or a slower common path. This page records both kinds of decision:
- Differences: where jlens behaves otherwise. A difference that affects the conformance suite is also an override there, with its reason.
- Extensions: features of jlens alone, which do not change the meaning of valid expressions.
- Quirks reproduced on purpose: jsonata-js behaviour that looks wrong, which jlens copies because its cost stays in unusual input or error paths, where users can expect the same answer from both engines.
Differences
Most differences have one of four causes: Rust strings are UTF-8 where JavaScript’s are UTF-16; jlens never panics, overflows its stack or exhausts memory on user input, and reports a coded error instead; an evaluation never changes its input; and a result never depends on the machine, its time zone or JavaScript’s internals.
Strings and Unicode
| Topic | jsonata-js | jlens |
|---|---|---|
| String representation | UTF-16, may contain lone surrogates | UTF-8 (String). A lone surrogate written as \uD800 becomes U+FFFD |
\uXXXX surrogate pairs | Two code units | Combined into one character, so "𝄞" is 𝄞 |
Error position | UTF-16 offset | The same UTF-16 offset, plus a byte span |
Two suite cases pass a lone surrogate to $encodeUrl / $encodeUrlComponent to provoke an
error. jlens cannot represent that input, so it skips those two cases.
Parsing
- Error recovery (
parse_recover) produces the same codes and positions as jsonata-js on its recovery tests. It also recovers from lexer errors and post-processing errors, which jsonata-js throws even in recover mode. - An expression that starts right after a recovered error reuses that error. jsonata-js reports
an extra
S0211with anundefinedtoken in that situation. - Doc-comments (
/** ... */) are parsed as audit annotations. Like any comment, they never change how an expression is parsed or evaluated.
Evaluation
| Topic | jsonata-js | jlens |
|---|---|---|
| Object key order | JavaScript order: integer-like keys first, ascending, then insertion order | Insertion order, always |
| Recursion limit | The JavaScript stack (RangeError) | D1011 after Options::max_depth evaluation levels (50,000 by default; 500 in Wasm) |
| Nesting limit | The JavaScript stack: about 2,000 terms in a chain such as 1 + 1 + … work, 5,000 raise a RangeError | U1001 beyond 1,000 levels of syntax tree: nested subexpressions, and each operator of a chain (a chain of more than 1,000 terms is rejected) |
| Very long strings and arrays | Limited by the JavaScript engine (a RangeError, or running out of memory) | U1001 beyond Options::max_string_length (256 MiB) or Options::max_array_length (16,777,216 items) |
| A very large result, such as an object nested in itself many times | Limited by the JavaScript engine when the result is serialized | U1001 beyond Options::max_result_size (256 MiB of JSON text) |
| A transform that makes an object contain itself (`$ ~> | $ | {‘self’: $} |
| Several items of an array constructor, or values of an object constructor, fail | The items are evaluated concurrently; the error raised first in the asynchronous schedule wins, which need not be the first item’s | The items are evaluated in order; the first failing item’s error is reported |
A group-by ({...}) over an empty array in the input | Adds an undefined item to that array of the input, which the rest of the expression sees (($g := items{'k': 1}; $count(items)) gives 1 when items is []; jlens gives 0) | The input is never changed |
A wildcard over an array of sequences ($map([1, 2], function($e){ items.$ }).*) | Adds a true item after the items of each sequence: an internal flag of the sequence, which its wildcard enumerates | The items only |
Without an input, a path whose first step is an array constructor with a wildcard ([*].price) | The wildcard runs over the internal sequence [undefined] and gives [undefined, true], its flag included, so the path goes on: undefined (and {[*].price: 1} gives {}) | The wildcard gives nothing, and the path is [], as [$].price is in both engines (and {[*].price: 1} fails with T1003) |
A JavaScript exception inside jsonata-js (Cannot read properties of undefined, comp is not a function) | Thrown without an error code | A coded error, or a result |
The left side of ?? and ?: | Copied into a condition when parsing, and evaluated twice: once for the test, once for the result. Each level of nesting doubles the tree (22 levels of ((a ?: 1) ?: 1)… take 12 seconds to parse); ?? calls whatever $exists is bound to | Evaluated once; the tree grows with the source |
A regular expression literal on the left of ?? or ?: ($match('abc', /b/) ?? 1) | The parser copies the left side through JSON, which turns the regex into {}; $match and $split then loop until memory runs out, and the timeout cannot stop them | Evaluated as written |
The keys of an order-by (items^(price)) | Evaluated for both items at every comparison: about 2·n·log₂ n evaluations for n items | Evaluated once per item, on first use, and kept. The keys are evaluated in the order jsonata-js first evaluates them, so results and the first error are the same; only a key with a side effect differs, such as one using $random, which draws once per item |
Error position and token inside function calls | Overwritten with the call site | Filled in only when the inner error has none, so it points at the real cause |
| Invalid regular expression | JavaScript SyntaxError without a code | S0302 with the engine message in message |
| A function as the final result | The function object | None, like JSON.stringify |
| Very large integers in results | Printed in full up to 1e21 | serde_json numbers. Integers above 2^53 become floats (same value, possibly in exponent notation) |
$toMillis of a date-time without an offset | Local time (per Date.parse) | UTC, so results never depend on the machine’s time zone |
A regular expression applied to a value that is not a string (x ~> /re/) | RegExp.prototype.exec converts the value with JavaScript’s String(): undefined is matched as “undefined”, so missing ~> /de/ finds de; an object becomes “[object Object]”, an array its items joined by commas | Undefined, as for a function given a missing argument. The conversion exposes JavaScript’s String() |
$formatNumber with irregular grouping ('#,##,##0') on a number with no digit left of a grouping position | Separators at the start or in the wrong place: $formatNumber(1234, '#,##,##0') is “12,34,” | “1,234”: a separator needs a digit on its left, as the picture rules give. Indian grouping of small numbers is ordinary input |
$substringBefore, $substringAfter when chars is undefined | Search for the text “undefined”: $substringBefore('a-undefined-b', missing) is “a-” | The string unchanged. The search exposes JavaScript’s String(undefined) |
$fromMillis with a name presentation for fractional seconds ('[fn]') | A JavaScript TypeError without a code | D3133, as for the other components that have no names ('[Yn]') |
Rounding a very large number to many decimals ($round(1e300, 20), $formatNumber(1e300, '#.00000000000000000000')) | Shifting the decimal point overflows to Infinity, and shifting it back gives NaN: $round returns null, $formatNumber writes “NaN” | The number unchanged: a number that large has no fractional digits |
$formatNumber of a number from 1e21, after the scaling of % and ‰ ($formatNumber(1e21, '#,##0.00'), $formatNumber(1e20, '0%')) | JavaScript’s exponent notation goes through the picture: “1e,+21.00”, “1e+22%” | The digits of the number: “1,000,000,000,000,000,000,000.00”. As below 1e21, in both engines, they are the exact value of the binary number: $formatNumber(1e23, '0') is “99999999999999991611392” |
$formatBase of a number from 2^53 in a radix that is not a power of two ($formatBase(1e30, 3)) | Zeros for the digits below the number’s precision | Every digit exact. A radix that is a power of two gives the same digits in both engines |
A number that overflows to infinity (1e308 * 10, 1 / 0, $sum([1e308, 1e308]), $round(1.7e308, -308), $parseInteger of a 400-digit number) | Infinity, which goes on: a result writes it as null in JSON, a function takes it ($string fails with D3001, $formatNumber writes “Infinity”), and the next operator, $boolean or $sort fails with D1001 | D1001 where the number overflows, as $power(10, 400) (D3061) and the literal 1e400 (S0102) fail in both engines. JSON has no infinity, so a result never becomes null without notice. $average of numbers whose sum overflows gives their mean (1e308 for [1e308, 1e308]). 0 / 0 is NaN in both engines |
A date/time picture marker with an unknown component and a presentation ($fromMillis(0, '[Qx]'), '[,2]') | The text “undefined” in the result. With no presentation ('[Q]') it fails with D3132 | D3132 in every case. The text exposes JavaScript’s undefined. $toMillis gives D3133 for both, as jsonata-js does |
A date beyond JavaScript’s range, ±8.64e15 ms (100,000,000 days) around 1970, after the time zone of $fromMillis ($fromMillis(1e17), $fromMillis(8.64e15, '[Y]', '+0100')) | Every component is NaN: “0NaN-NaN-NaNTNaN:NaN:NaN.NaNZ”; words for one ('[Yw]') recurse until the stack overflows | D1001, as a number that overflows to infinity. Within the range every component is exact, also where it counts from a date beyond it: $fromMillis(-8.64e15, '[Y] [d] [W]') is “-271821 110 16”, where jsonata-js writes “-271821 NaN NaN”. $toMillis gives no date beyond the range in both engines, and jlens checks it after the time zone too, so $fromMillis takes every date it gives |
$fromMillis or $now with a timezone that does not start with a number ($fromMillis(0, undefined, "two"), "") | parseInt gives NaN, and every component is written as “NaN”: “0NaN-NaN-NaNTNaN:NaN:NaN.NaN0N:aN” | D3150. The text exposes JavaScript’s NaN. A timezone that starts with a number is read as far as its digits go, as parseInt reads it in both engines ("+0530x" is +05:30) |
Words or letters for NaN or infinity, which only a Source can give jlens: letters or words for infinity (in jsonata-js, $formatInteger(1e308 * 10, 'a')) | Words recurse until the stack overflows (RangeError); letters loop forever | Nothing (“”), as roman numerals and letters write for NaN in both engines; digits write “NaN” |
$parseInteger with a picture whose digits are not ASCII, on text with characters below that digit family ($parseInteger('12', '##0'), $parseInteger('12x', '0')) | A RangeError without a code (Invalid code point) | The character ends the number, as any other non-digit does: NaN for the first, 12 for the second |
$split(s, "") | Splits into UTF-16 code units | Splits into characters (astral characters stay whole) |
$random, $shuffle | Math.random | A per-evaluation xorshift generator seeded from the clock |
The timestamp of $now and $millis | The clock when the evaluation starts | The clock when the evaluation first needs it, or the seed of $random; read once, so it stays fixed for the evaluation. An audited evaluation reads it when it starts |
| Regular expressions | JavaScript RegExp | jlens-regress, a fork of regress 0.12.0 (ECMAScript syntax and semantics). Match positions are UTF-16 offsets. A pattern matches characters, not UTF-16 code units: $match('a𝄞', /[^a]/) matches 𝄞, where jsonata-js matches its first half, a lone surrogate. A pattern nested more than 256 levels deep is a syntax error (S0302) |
A regular expression that backtracks catastrophically, such as /^(a+)+$/ on a long run of a followed by ! | Runs until the match ends, which can take years, and blocks the thread | The same without limits. With Options::timeout or a cancellation token, the match stops with D1012 or U1003, like any other step (ADR 0009) |
The suite runner’s own guard raises U1001 for its timelimit/depth cases; jlens enforces
the same limits through Options and reports D1011/D1012, which the conformance harness
accepts in place of U1001.
The input in WebAssembly
jsonata-js evaluates the JavaScript object it is given. jlens-wasm evaluate reads that object
as JSON.stringify does, one object or array at a time, when the expression reaches it
(ADR 0010):
| Topic | jsonata-js | jlens |
|---|---|---|
A Date, a boxed primitive (new Number(1)), an object with a toJSON method | The object itself | What JSON.stringify writes: a Date is its ISO 8601 string |
undefined, functions and symbols in the input | Visible to the expression | Left out of objects, and null in arrays, as in JSON.stringify |
A cycle or a BigInt | Read as they are | The TypeError that JSON.stringify throws, when the expression reads the object or array that holds them. An object or array of more than 16 values is read whole, so the error comes when it is read, wherever it is inside |
| A host function that modifies the input | Later reads see the change | The same for the parts not read yet; a part already read keeps what was read |
Host functions
Functions provided by the application through the bindings (Bindings::function in Rust,
functions in the bindings object or registerFunction in the WebAssembly package):
| Topic | jsonata-js | jlens |
|---|---|---|
| Arguments and result | The JavaScript values themselves, which the function may modify | JSON copies: the function cannot change the values of the evaluation |
A function as an argument ($f($g)) | An async function that applies it, with an arity property | In Rust, None among the arguments, and HostCall::function gives a Callable (ADR 0011). In WebAssembly, a function with an arity property, as in jsonata-js |
| Calling a function argument | Returns a promise | Synchronous: returns the result. In WebAssembly, await f(x) keeps working; f(x).then(...) does not |
| Calling a function argument after the host function returns | Works: the function holds its environment | Not possible in Rust (the Callable borrows the call); in WebAssembly, it throws U1005 |
| The result of a function called by the host | The internal value: a sequence keeps its sequence flag, a function is an internal object | JSON: a sequence is an array or a single value, a function is None |
| A recursion through a host function | Runs until the heap is exhausted | D1011, as any recursion too deep |
| The context value | this: the context value for a function in the bindings, {environment, input} for one registered with registerFunction. An input array arrives in its wrapper | HostCall::input, the context value itself, an input array included. In WebAssembly, this is undefined: a signature with - passes the context value as an argument |
| A function that returns a promise | The evaluation awaits it | U1002 |
| An exception thrown by the function | Rethrown, with the position and token of the call | Rethrown as is in WebAssembly; position and token are added when missing |
Extensions
These features exist only in jlens. Expressions that use them remain valid JSONata, because the extensions live in comments or in the host API, not in the language syntax.
- Audit annotations and the warnings
W0101–W0103. Astnode ids and spans, andparse_recoverdiagnostics with severities.- Static warnings (
W0201–W0205) and the language service. Document, for evaluating many expressions against one converted document.
Quirks reproduced on purpose
Both engines behave as described here. Each row gives the reason for copying it.
| Topic | jsonata-js and jlens | Decision |
|---|---|---|
$formatInteger in letters ('a', 'A') of a number above 2^53 | Computed in floating point: value - 1 rounds to value, and the letters follow that rounding | Kept. The number is already a rounded double, so exact arithmetic on it gains no real precision, and letter numbering of such numbers is not ordinary input |
$power(±1, e) with e undefined | D3061: JavaScript’s Math.pow gives NaN for 1^NaN, where IEEE pow gives 1 | Kept. Every base fails with D3061 when the exponent is undefined, ±1 included |
$base64encode, $base64decode | Take the low byte of each UTF-16 code unit (Node’s Buffer), so $base64decode reads Ĵ (U+0134) as the digit 4, and text outside Latin-1 does not round-trip | Kept. Latin-1 text gives the same Base64 in both engines, which data exchanged between them relies on; encoding UTF-8 instead would change the result for any text outside ASCII |
Command-line interface
The jlens binary evaluates, checks, explains and inspects JSONata expressions.
jlens eval <EXPR> | -f FILE [-i INPUT | --no-input] [--ndjson] [--filter]
[-b BINDINGS] [-c | -r | --json] [--timeout MS] [--max-depth N]
[--max-string-length BYTES] [--max-array-length N] [--max-result-size BYTES]
[--now TIME] [--seed N]
[--explain | --audit json|text] [--audit-level decisions|full] [--trace-log FILE]
jlens check <EXPR> | -f FILE... [--json] [--deny-warnings] [--schema SCHEMA]
[--config FILE] [--path FILE]
jlens test FILE... [--json] [--timeout MS]
[--max-string-length BYTES] [--max-array-length N] [--max-result-size BYTES]
jlens functions [NAME] [--json]
jlens errors [CODE] [--json]
jlens schema infer SAMPLES... [--each] [--overrides FILE] [-o SCHEMA]
jlens schema complete <EXPR> | -f FILE [--schema SCHEMA] [--cursor N] [--json]
jlens ast <EXPR> | -f FILE [--tokens | --raw] [--recover]
Every command takes the expression either as an argument or from a file with -f. Prefer -f
for anything longer than a one-liner. Shells interpret $, " and * differently, and Windows
shells (cmd.exe, PowerShell) in particular make quoting expressions awkward. Glob patterns in
jlens check -f, jlens test and jlens schema infer are expanded by jlens itself, so they
work in every shell.
Exit codes
| Code | Meaning |
|---|---|
0 | Success. check may still have printed warnings (unless --deny-warnings) |
1 | The expression has errors: a syntax error, an evaluation error, or (with --ndjson) at least one line that failed; test: at least one case failed |
2 | Usage or I/O problem: unreadable file, invalid JSON input, an invalid case file, bad arguments, a terminal on standard input |
Writing to a closed pipe, as in jlens functions | head, ends quietly with code 0.
For scripts and agents
jlens keeps a contract with the programs that run it, recorded in
ADR 0008:
- Standard output is data: results, JSON from
--json, catalogs. Standard error is for people: rendered diagnostics, summaries, usage errors. Never parse standard error. - Decide on the exit code (see the table above), not on whether anything was printed: an undefined result prints nothing and still exits with 0.
- Match errors by code (
S0202,T2001,D1012…), never by message. Codes are listed in the error reference. - The format never depends on the terminal. A command prints the same thing when an agent runs it and when a person copies it into a shell. Ask for machine output with a flag.
- Pass the expression with
-f FILEand the input with-i FILEor--no-input. This avoids shell quoting. Anevalthat would read its input from a terminal fails with code 2 instead of waiting. - Use
eval --jsonto get every outcome, errors included, as one line of JSON on standard output (see below), andcheck --jsonfor the diagnostics of every file as one JSON array. - Test the expressions you write with
jlens testand a file of cases, and read the failures fromtest --json(see below). - Fix the clock and the seed with
--nowand--seedwhen a result depends on$now(),$millis(),$random()or$shuffle(), so that the same command prints the same bytes when it is run again.
jlens eval
Evaluates the expression against a JSON document and prints the result. The document comes from
-i FILE, or from standard input. An undefined result prints nothing. When standard input is a
terminal and neither -i nor --no-input is given, eval fails with exit code 2 instead of
waiting for a document.
$ jlens eval -f fixtures/shipping-rule.jsonata -i fixtures/online-store-order.json
{
"action": "FREE_SHIPPING",
"code": "OK_200"
}
| Option | Effect |
|---|---|
-i, --input FILE | The input document (default: standard input) |
--no-input | Evaluate without an input document ($ is undefined) |
-b, --bindings FILE | A JSON object whose fields are bound as variables: {"limit": 10} makes $limit available |
-c, --compact | One line per result |
-r, --raw-output | Print strings without quotes, like jq -r |
--json | Print a JSON envelope instead of the bare result (see below) |
--timeout MS | Stop an evaluation that runs longer (error D1012) |
--max-depth N | Stop an evaluation that nests deeper (error D1011); the default is 50,000 |
--max-string-length BYTES | Stop an evaluation that builds a longer string (error U1001); the default is 256 MiB |
--max-array-length N | Stop an evaluation that builds an array of more items (error U1001); the default is 16,777,216 |
--max-result-size BYTES | Stop an evaluation whose result is larger as compact JSON, counting a value each time it appears (error U1001); the default is 256 MiB |
--now TIME | The time seen by $now() and $millis(): milliseconds since the epoch, or an ISO 8601 timestamp such as 2026-01-01T00:00:00Z |
--seed N | Seed $random() and $shuffle(): the same seed gives the same results |
Results are printed the way JavaScript prints JSON, so they match jsonata-js output: 2^64 prints
as 18446744073709552000 and 1e21 as 1e+21.
Machine output: --json
With --json, every outcome is one compact JSON object, the envelope, on one line of
standard output. Nothing needs to be read from standard error.
| Outcome | Envelope |
|---|---|
| A result | {"ok":true,"result":…} |
| An undefined result | {"ok":true,"undefined":true} |
| An error | {"ok":false,"error":{"code":…,"message":…,"position":…,"line":…,"column":…}} |
$ jlens eval --json -i fixtures/online-store-order.json 'customer.name + 1'
{"ok":false,"error":{"code":"T2001","message":"The left side of the \"+\" operator must evaluate to a number","position":15,"line":1,"column":1}}
- Decide on
ok, and match errors bycode;messageis for people and may change. lineandcolumn(1-based, the column in characters) mark the start of the offending text in the expression, as the human report does.positionis the jsonata-js position, a UTF-16 offset usually just past the offending token. The three are absent when the error has no location.- An input document that is not valid JSON gives an envelope with code
U2001, and exit code 2 as without--json. - With
--filter, a matching document is theresult, and a document that does not match prints nothing. - With
--ndjson, every envelope also carriesline, the number of its input line, so that results and errors can be paired with their input. A line that is not valid JSON gives aU2001envelope, and processing continues. --jsoncannot be combined with-r,--explainor--audit.- Fields are only ever added to the envelope: ignore the ones you do not know.
Many documents: --ndjson
With --ndjson, the input has one JSON document per line, and the expression is evaluated for
each one. Results are printed one per line, as they are produced, so arbitrarily large inputs
stream through in constant memory.
- Blank lines are skipped.
- Lines that are not valid JSON, and evaluations that fail, are reported on standard error with their line number. Processing continues, and the exit code becomes 1.
- With
--json, each result and each error is an envelope that carries its line number, and nothing is written to standard error.
$ jlens eval --ndjson -i fixtures/orders.ndjson '$sum(items.(unitPrice * quantity))'
50000
300
60000
line 5: invalid JSON: expected ident at line 1 column 2
$ jlens eval --ndjson --json -i fixtures/orders.ndjson '$sum(items.(unitPrice * quantity))'
{"ok":true,"line":1,"result":50000}
{"ok":true,"line":2,"result":300}
{"ok":true,"line":4,"result":60000}
{"ok":false,"line":5,"error":{"code":"U2001","message":"The input is not valid JSON: expected ident at line 1 column 2"}}
{"ok":true,"line":6,"undefined":true}
Selecting documents: --filter
With --filter, jlens prints the input documents for which the expression is truthy,
instead of the results. Truthiness follows $boolean: false, 0, "", null, empty arrays,
empty objects and undefined are false. Combined with --ndjson, this works like a grep for
JSON:
$ jlens eval --ndjson --filter -i fixtures/orders.ndjson \
"customer.login in ['alice.walker99', 'bob.harrison42'] and \$sum(items.(unitPrice * quantity)) > 50000"
{"number":"SO-3","customer":{"login":"bob.harrison42"},"items":[{"type":"toy","unitPrice":60000,"quantity":1}]}
Explaining results
| Option | Effect |
|---|---|
--explain | Prints an explanation of how the result was reached, instead of the result |
--audit json | Prints the full audit tree as JSON (--audit text is the same as --explain) |
--audit-level full | Records every evaluated node, not only decisions |
--trace-log FILE | Writes every evaluation event to FILE as JSON Lines; combines with the options above |
$ jlens eval --no-input --explain '/** @label "Positive" */ 3 > 0 and /** @label "Small" */ 3 < 2'
result: false
✘ Small — 3 < 2 → false (3 < 2)
These options apply to a single document; they cannot be combined with --ndjson or
--filter.
jlens check
Parses the expression with error recovery and reports every problem it finds. It warns about
calls of functions that are neither built in nor bound in the expression (W0202, with a
suggestion such as $summ → $sum), and about calls with a number of arguments the function’s
signature does not accept (W0203) or an argument whose type it does not accept (W0205, as
in $length(1234)). With --schema, it also reports field names that the schema does not have
(see the schema guide), and checks the types of fields passed to functions. Each
problem is shown in context:
$ jlens check 'Account.Order[0;].Product'
error[S0202]: Expected "]", got ";"
--> <expr>:1:16
|
1 | Account.Order[0;].Product
| ^
<expr>: 1 error(s), 0 warning(s)
-f takes several files, and glob patterns such as rules/*.jsonata. Each file is reported in
turn, followed by a total. Errors make the exit code 1; warnings alone do not, unless
--deny-warnings is given. An unreadable file, or a pattern that matches nothing, ends with exit
code 2 before anything is checked.
With --json, the diagnostics of all the files are printed as one compact JSON array on
standard output, which suits editors, CI scripts and agents. Each diagnostic has file (absent
for an expression given as an argument), the 1-based line and column (counted in
characters), then the severity, code, message and span, in byte offsets into the file:
$ jlens check --json 'a +'
[{"line":1,"column":4,"severity":"error","code":"S0207","message":"Unexpected end of expression","span":{"start":3,"end":3}}]
Configuration: jlens.json
check reads the same jlens.json as the language
server, so that the command line and the editors report the same problems. It maps file
patterns to a schema, the variables and the host functions the expressions are evaluated with:
calls of declared host functions are checked like those of built-in ones, and, once variables
are declared, references to undeclared ones get W0204. By default, check reads the
jlens.json of the current directory, if there is one; --config names another file, whose
patterns and paths are relative to its folder.
The configuration applies to files given with -f, by their path. An expression given as an
argument matches no pattern, unless --path gives it the path of a file it would be saved as,
which need not exist; that suits checking an expression before writing it:
jlens check --path rules/discount.jsonata '$rate(type) * unitPrice'
--schema replaces the schema that the configuration gives a
file, and keeps its variables and functions. A configuration that cannot be read, or whose
schema cannot be, ends with exit code 2.
To validate a directory of rules in CI, warnings included:
jlens check --deny-warnings -f 'rules/*.jsonata'
jlens test
Runs files of test cases. A case file is a JSON object that gives one expression, inline with
expr or as the path of a file with file (relative to the case file), and the cases it is
evaluated with:
{
"file": "../rules/discount.jsonata",
"bindings": {"discount": 0.1},
"cases": [
{"name": "ten percent off", "input": {"items": [{"price": 10}, {"price": 30}]}, "result": 36},
{"name": "no items", "input": {}, "undefined": true},
{"name": "text prices", "input": {"items": [{"price": "10"}]}, "error": "T0412"}
]
}
Each case has an optional name, the input document (absent for none; null is a
document), its own bindings, which are added to those of the file and win over them, and
exactly one expectation:
| Key | The case passes when |
|---|---|
"result": VALUE | the result equals VALUE. Numbers compare by value (1 equals 1.0), and the keys of objects in any order |
"undefined": true | the result is undefined |
"error": "CODE" | the evaluation, or the compilation, fails with this error code. Messages are not compared |
The file may also fix the clock with now (milliseconds or an ISO 8601 timestamp, as
eval --now) and the random numbers with seed. Unknown keys are errors, so that a misspelt
expectation does not pass unnoticed.
jlens test takes several files, and glob patterns such as tests/*.json. It reports each
failed case, with what was expected and what the expression gave, then a summary per file. Had
the third case above expected T2001:
$ jlens test tests/discount.json
FAIL tests/discount.json case 3: text prices
expected: error T2001
actual: error T0412: Argument 1 of function "sum" must be an array of "numbers"
tests/discount.json: 2 passed, 1 failed
A failed case makes the exit code 1. A case file that cannot be read, or is invalid, ends with
exit code 2 before any case runs. --timeout stops each evaluation after a number of
milliseconds (error D1012), and --max-string-length, --max-array-length and
--max-result-size cap the size of what it builds, as they do for eval (error U1001).
With --json, the outcome is one compact JSON object on standard output, with the number of
cases that passed and failed, and the failures. Each failure has the file, the 1-based
case number, the name if there is one, and what was expected and what is actual, both in
the shape of the eval --json envelope:
$ jlens test --json tests/discount.json
{"passed":2,"failed":1,"failures":[{"file":"tests/discount.json","case":3,"name":"text prices","expected":{"ok":false,"error":{"code":"T2001"}},"actual":{"ok":false,"error":{"code":"T0412","message":"Argument 1 of function \"sum\" must be an array of \"numbers\"","position":5,"line":1,"column":1}}}]}
jlens functions
Lists the built-in functions by category, with their parameters and a summary. Given a name
(with or without $), it describes one function; --json prints the catalog as JSON.
$ jlens functions formatNumber
$formatNumber($value, $picture, $options)
signature: <n-so?:s>
category: Numeric
Formats a number with an XPath picture string
The function reference is generated from the same catalog.
jlens errors
Lists the error and warning codes by category, with their message templates. Given a code (in
any case), it describes one; --json prints the catalog as JSON, each entry with its code,
category (static, type, dynamic, guardrail or warning) and template.
$ jlens errors S0202
S0202
category: Static errors (S)
message: Expected {{value}}, got {{token}}
The error reference is generated from the same catalog.
jlens schema
| Command | Purpose |
|---|---|
jlens schema infer SAMPLES… [--each] [--overrides FILE] [-o SCHEMA] | Infer a JSON Schema from sample documents |
jlens schema complete <EXPR> --schema SCHEMA [--cursor N] [--json] | List completions at a cursor position, given in characters (default: the end) |
Both are described in the schema guide.
jlens lsp
Runs the language server on standard input and output. Editors start it; it is configured by
the jlens.json of each workspace folder. See the editors guide.
jlens ast
Prints the processed syntax tree as JSON. This is the tree the evaluator walks, with node ids, spans, stages and annotations. Flags show earlier stages of the pipeline:
| Flag | Output |
|---|---|
| (none) | Processed tree (Ast) |
--raw | Raw parse tree, before post-processing |
--tokens | Token stream, with spans |
--recover | Keep going after syntax errors; print the partial tree, with diagnostics on standard error |
jlens ast is mostly useful for understanding how an expression is read, for example which part
an annotation covers. The parsing chapter
explains the tree.
Rust crate
jlens-core is the JSONata engine. jlens-schema adds schema inference and the language
service; see the schema guide. This chapter covers jlens-core. Its API reference
is on docs.rs.
[dependencies]
jlens-core = "0.2"
serde_json = "1"
The snippets in this chapter come from crates/jlens-core/tests/guide.rs, which runs them as
tests.
Compile once, evaluate many times
use jlens_core::{Bindings, Expression};
use serde_json::json;
let expr = Expression::compile("$sum(items[type = $kind].(price * qty))")?;
let order = json!({"items": [
{"type": "book", "price": 12.5, "qty": 2},
{"type": "pen", "price": 1.5, "qty": 10}
]});
let bindings = Bindings::new().var("kind", "book");
let total = expr.evaluate(Some(&order), &bindings)?;
assert_eq!(total, Some(json!(25)));
Expression::compile
parses the expression and prepares it for evaluation. A compiled expression is immutable and
cheap to clone. Keep it, and evaluate it as often as needed, from any thread (see
Threads).
Documents and results are serde_json::Values. Object fields keep their order (jlens enables
serde_json’s preserve_order). Variables come from
Bindings: $kind above.
Bindings can also be collected from (String, Value) pairs, such as the fields of a JSON object.
Undefined
JSONata distinguishes nothing (an empty sequence) from null. In the API, nothing is None,
both for the input and for the result:
let expr = Expression::compile("customer.email")?;
// No input document: `None`. A result of `None`: the expression produced nothing.
assert_eq!(expr.evaluate(None, &Bindings::new())?, None);
assert_eq!(
expr.evaluate(Some(&json!({"customer": {}})), &Bindings::new())?,
None
);
Guardrails
Expressions can recurse and loop. Bound their cost with
Options, especially for
expressions that users write:
use jlens_core::Options;
use std::time::Duration;
let options = Options::new()
.timeout(Duration::from_millis(100)) // D1012 when exceeded
.max_depth(500); // D1011 when exceeded
let runaway = Expression::compile("($f := function($n) { $f($n + 1) }; $f(0))")?;
let err = runaway
.evaluate_with(None, &Bindings::new(), &options)
.unwrap_err();
assert_eq!(err.code(), "D1012");
The default depth limit is 50,000 levels natively; the stack grows as needed. There is no
default timeout. Expressions nested more than 1,000 levels deep (counting each operator of a
chain such as 1 + 2 + 3) are rejected when compiled (U1001). A string longer than Options::max_string_length (256 MiB), an array longer
than Options::max_array_length (16,777,216 items) or a result larger than
Options::max_result_size (256 MiB of JSON text) stops the evaluation with U1001
instead of exhausting memory. See Guardrails.
Cancellation
A CancelToken stops an
evaluation from another thread, for example when the request that started it goes away. Clones
of a token share one flag:
use jlens_core::CancelToken;
use std::{thread, time::Duration};
let token = CancelToken::new();
let options = Options::new().cancel_token(token.clone());
let forever = Expression::compile("($loop := function() { $loop() }; $loop())")?;
let worker = thread::spawn(move || forever.evaluate_with(None, &Bindings::new(), &options));
thread::sleep(Duration::from_millis(50));
token.cancel(); // from any thread that holds a clone
let err = worker.join().unwrap().unwrap_err();
assert_eq!(err.code(), "U1003");
The evaluation checks the token between steps, as it checks the timeout, and fails with
U1003. A host function that is running finishes first. A cancelled token stays cancelled, so
create one per job.
Reproducible results
$now() and $millis() read the system clock once per evaluation, and $random() and
$shuffle() draw from a generator seeded from it. Fix both in Options to get the same result
from the same expression, input and options, for tests or for replaying a decision:
let options = Options::new()
.now(1_700_000_000_000) // $now(), $millis()
.seed(42); // $random(), $shuffle()
let expr = Expression::compile("{'at': $now(), 'order': $shuffle([1, 2, 3, 4, 5])}")?;
let bindings = Bindings::new();
let first = expr.evaluate_with(None, &bindings, &options)?.unwrap();
assert_eq!(first["at"], json!("2023-11-14T22:13:20.000Z"));
// Same expression, input and options: the same result.
assert_eq!(expr.evaluate_with(None, &bindings, &options)?, Some(first));
A seed reproduces results with the same version of jlens, on any platform; it does not
reproduce the numbers jsonata-js draws from Math.random().
Errors
Every error has a stable code: S0xxx for syntax errors, T for
type errors, D for dynamic errors, and U1001 for limits. Match on the code, not on the
message:
let err = Expression::compile("Account.Order[0;]").unwrap_err();
assert_eq!(err.code(), "S0202");
assert_eq!(err.message(), r#"Expected "]", got ";""#);
assert_eq!(err.position(), Some(16)); // UTF-16 offset, as in jsonata-js
let span = err.span().unwrap(); // byte range of the offending token
assert_eq!((span.start, span.end), (15, 16));
assert_eq!(err.token().as_deref(), Some(";"));
let expr = Expression::compile("name + 1").unwrap();
let err = expr
.evaluate(Some(&json!({"name": "x"})), &Bindings::new())
.unwrap_err();
assert_eq!(err.code(), "T2001");
assert_eq!(err.value(), Some(&json!("x")));
// `Display` gives the code, the message and the position.
assert_eq!(
err.to_string(),
r#"T2001: The left side of the "+" operator must evaluate to a number (at position 6)"#
);
position matches jsonata-js: a UTF-16 offset, usually just after the offending token. span
is a byte range, for slicing the source in Rust. Errors serialize to JSON with their code,
message and details.
Host functions
The application can provide its own functions, called from expressions like built-in ones. Bind
a HostFunction with
Bindings::function:
use jlens_core::{Error, HostFunction};
// `$shipping(weight, region?)`: the region defaults to the context value's `region`.
let shipping = HostFunction::with_signature("<ns?:n>", |args, call| {
let weight = args[0].as_ref().and_then(|w| w.as_f64()).unwrap_or(0.0);
let region = match &args[1] {
Some(region) => region.clone(),
None => call
.input()
.map_or(json!(null), |order| order["region"].clone()),
};
let rate = match region.as_str() {
Some("north") => 1.5,
Some("south") => 2.0,
_ => return Err(Error::new("SHIP01").with("message", format!("No rate for {region}"))),
};
Ok(Some(json!(weight * rate)))
})?;
let bindings = Bindings::new().function("shipping", shipping);
let expr = Expression::compile("$shipping($sum(items.weight))")?;
let order = json!({"region": "south", "items": [{"weight": 2}, {"weight": 1.5}]});
assert_eq!(expr.evaluate(Some(&order), &bindings)?, Some(json!(7)));
let err = Expression::compile("$shipping(3, 'west')")?
.evaluate(None, &bindings)
.unwrap_err();
assert_eq!(err.code(), "SHIP01");
assert_eq!(err.message(), r#"No rate for "west""#);
assert_eq!(err.position(), Some(10)); // the call, as for built-in functions
- Arguments and results are JSON.
Nonestands for undefined. A function passed as an argument (such as a lambda) arrives asNone;HostCall::functiongives a handle to call it (below). - Signatures use the notation of the built-in functions.
HostFunction::with_signaturechecks the arguments before each call, with the same errors (T0410,T0412). A parameter with-takes the context value when the argument is missing.HostFunction::new(arity, ...)skips the check and passes the arguments as given. - The arity tells higher-order functions how many arguments to pass:
$mappasses the index only to functions with two parameters or more. Partial application ($shipping(?, 'north')) uses it too. - The context value is available through
HostCall::input, which converts it to JSON only when called. - Errors returned by the function stop the evaluation. Give them a code of your own and a
messagedetail. Like other errors, they get the position and token of the call. - Bindings take precedence over built-in functions of the same name, as in jsonata-js. A name holds a value or a function: binding one replaces the other.
Host functions are Send + Sync, so bindings that hold them can be shared between threads. A
host function runs to completion: a timeout is checked again only once it
returns, or when it calls a function it received.
Functions as arguments
A host function can call a function it receives, such as a fallback or a step to retry.
HostCall::function(i) gives a
Callable for argument i
when it is a function: a lambda, a built-in function, a partial application, a chain or another
host function.
// `$rate(region, fallback)`: the rate from the application's table, or `fallback(region)`.
let rate = HostFunction::with_signature("<sf:n>", |args, call| {
match args[0].as_ref().and_then(|r| r.as_str()) {
Some("north") => Ok(Some(json!(1.5))),
Some("south") => Ok(Some(json!(2))),
_ => {
let fallback = call.function(1).expect("the signature requires a function");
fallback.call(&[args[0].clone()])
}
}
})?;
let bindings = Bindings::new().function("rate", rate);
let expr = Expression::compile("$rate(region, function($r) { $length($r) / 10 })")?;
let order = json!({"region": "west"});
assert_eq!(expr.evaluate(Some(&order), &bindings)?, Some(json!(0.4)));
// An error in the fallback comes back to the host function, which returns it here.
let err = Expression::compile("$rate('west', function($r) { $error('No rate for ' & $r) })")?
.evaluate(None, &bindings)
.unwrap_err();
assert_eq!(err.code(), "D3137");
assert_eq!(err.message(), "No rate for west");
- The call is part of the evaluation, as when
$mapcalls its function. The context value isnull, and a lambda keeps the variables of its definition. The call counts as a level of depth, so a recursion through a host function ends withD1011; it checks the timeout and the cancellation token; its result is limited bymax_result_size. An audit shows its steps under the host function’s call. - Arguments and the result are JSON, as for the host function itself. A sequence comes back
as an array or a single value; a function crosses as
None, in both directions. - Errors come back as
Err, with their codes and positions. The host function can handle them, or return them, which ends the evaluation. - The handle borrows the call: it cannot be kept and called after the host function returns.
Many expressions, one document
Each evaluate call converts the parts of its input document that the expression reads into
jlens’s internal representation, one level at a time: a path costs the width of the objects it
crosses. An expression that reads most of a document of a few dozen kilobytes (a filter over
every item, say) spends more on that conversion than on the rule. To evaluate several rules
against the same document, convert it once into a
Document:
use jlens_core::Document;
let rules = [
("minimum total", "$sum(items.(price * qty)) >= 20"),
("at most 50 items", "$sum(items.qty) <= 50"),
("no pens", "$not('pen' in items.type)"),
];
let compiled: Vec<_> = rules
.iter()
.map(|(name, source)| Ok((*name, Expression::compile(source)?)))
.collect::<Result<_>>()?;
let order = Document::parse(
r#"{"items": [{"type": "book", "price": 12.5, "qty": 2},
{"type": "pen", "price": 1.5, "qty": 10}]}"#,
)?;
let failed: Vec<&str> = compiled
.iter()
.filter(|(_, rule)| {
let outcome = rule.evaluate_document(&order, &Bindings::new(), &Options::default());
outcome.ok().flatten() != Some(json!(true))
})
.map(|(name, _)| *name)
.collect();
assert_eq!(failed, ["no pens"]);
Document::parse reads JSON text straight into the internal representation, and
Document::new converts a serde_json::Value. A Document is not Send: use it on the thread
that created it. Performance has measurements.
Other input representations
An input held in another representation than serde_json (another JSON library, a database
row, a host language’s objects) can be read the same way, one level at a time, without being
converted first. Implement
input::Source for
it, and evaluate with Expression::evaluate_source. The documentation of the
input module has an
example. A level that the source fails to read stops the evaluation with U1004. The
WebAssembly package reads JavaScript objects this way.
Explaining results
evaluate_audited records an audit tree of the evaluation: comparisons and their operands,
and/or short circuits, conditionals, bindings, filters, and the labels of
annotations. explain summarizes it:
use jlens_core::audit::{AuditOptions, explain};
let rule = Expression::compile(
r#"/** @label "Big order" @message "Orders must total at least 100." */
$sum(items.(price * qty)) >= 100"#,
)?;
let order = json!({"items": [{"price": 12.5, "qty": 2}]});
let (result, audit) = rule.evaluate_audited(
Some(&order),
&Bindings::new(),
&Options::default(),
&AuditOptions::decisions(),
);
assert_eq!(result?, Some(json!(false)));
let explanation = explain(&audit);
assert_eq!(explanation.result, "false");
assert_eq!(
explanation.lines[0].text,
"Big order — $sum(items.(price * qty)) >= 100 → false (25 >= 100)"
);
// The whole tree serializes to JSON, to store it with the decision.
let stored = serde_json::to_string(&audit)?;
assert!(stored.contains(r#""label":"Big order""#));
The audit guide describes the tree, the levels and the bounds that keep audits of large documents small.
Custom tracers
Audits are built by a tracer, an implementation of the
Tracer trait that
receives evaluation events. Every method has a default that does nothing, so a tracer
implements only what it needs:
use jlens_core::Node;
use jlens_core::audit::{Tracer, ValueRef};
/// Counts the comparisons an evaluation makes.
#[derive(Default)]
struct Comparisons(usize);
impl Tracer for Comparisons {
fn operands(&mut self, _node: &Node, _lhs: ValueRef<'_>, _rhs: ValueRef<'_>) {
self.0 += 1;
}
}
let expr = Expression::compile("items[price > 10 and qty < 5]")?;
let mut counter = Comparisons::default();
expr.evaluate_traced(
Some(&json!({"items": [{"price": 12, "qty": 1}, {"price": 5, "qty": 9}]})),
&Bindings::new(),
&Options::default(),
&mut counter,
)?;
assert_eq!(counter.0, 3); // the second item fails `price > 10`, so `qty < 5` is skipped
jlens provides three tracers:
AuditCollectorbuilds audit trees;JsonlTracerwrites every event as a line of JSON;TracingTracer, with thetracingfeature, emitstracingevents.
Without a tracer, the evaluator runs its fast path, so tracing costs nothing when unused.
Tools for editors
parse_recover keeps
going after syntax errors. The syntax tree is public, with node ids, spans and annotations:
use jlens_core::{NodeKind, Severity, parse_recover};
// Recovering parsing never fails: it returns a partial tree and every problem found.
let ast = parse_recover("Account.Order[0;].Product");
let errors: Vec<_> = ast
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.map(|d| (d.code.as_str(), d.span.start))
.collect();
assert_eq!(errors, [("S0202", 15)]);
// Walk the tree, for example to list the variables an expression uses (functions are
// variables too).
let ast = jlens_core::parse("$a + $b * $sum(items)")?;
let mut variables = Vec::new();
ast.root.walk(&mut |node| {
if let NodeKind::Variable { name } = &node.kind {
variables.push(name.clone());
}
});
assert_eq!(variables, ["a", "b", "sum"]);
The function catalog describes every built-in function, for completion lists and documentation:
let catalog = jlens_core::function_catalog();
let substring = catalog.iter().find(|f| f.name == "substring").unwrap();
assert_eq!(substring.signature, "<s-nn?:s>");
assert_eq!(substring.params, ["str", "start", "length"]);
For completion, diagnostics and hover against a schema, use jlens_schema::LanguageService;
see the schema guide.
Threads
Expression is Send + Sync. Share one compiled expression between threads, for example
behind an Arc:
use std::sync::Arc;
use std::thread;
let expr = Arc::new(Expression::compile("$uppercase(name)")?);
let workers: Vec<_> = ["ann", "bob"]
.into_iter()
.map(|name| {
let expr = Arc::clone(&expr);
thread::spawn(move || expr.evaluate(Some(&json!({"name": name})), &Bindings::new()))
})
.collect();
for worker in workers {
assert!(worker.join().unwrap()?.is_some());
}
Each evaluation is independent and single-threaded. Values created during an evaluation never
leave it, except as the serde_json::Value result.
Features
| Feature | Effect |
|---|---|
tracing | Adds audit::TracingTracer, which reports evaluations as tracing spans and events |
The minimum supported Rust version is 1.88. jlens-core compiles to wasm32-unknown-unknown;
the WebAssembly guide covers the browser package built on it.
WebAssembly (browser)
jlens-wasm compiles jlens to WebAssembly as an ES module for browsers. It covers what the
Rust crates do:
- evaluation;
- audit trees and explanations;
- schema inference;
- the language service (completion, diagnostics, hover, signature help) for editors.
It also runs in Node.js 20+ and Deno, provided init is given the .wasm bytes.
import init, { Expression } from "jlens-wasm";
await init();
const expr = new Expression("$sum(items[type = $kind].(price * qty))");
expr.evaluate({ items: [{ type: "a", price: 2.5, qty: 4 }] }, { kind: "a" }); // 10
Building the package
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version <the wasm-bindgen version in Cargo.lock> --locked
cargo xtask wasm # release build into crates/jlens-wasm/pkg
cargo xtask wasm --dev # unoptimized, with Rust panics reported on the console
cargo xtask wasm does four things:
- It compiles the crate with the
wasm-releaseprofile, which optimizes for size and speed (opt-level = "s"). - It generates the JavaScript glue and the TypeScript declarations with
wasm-bindgen --target web. - It runs
wasm-opt -Osfrom binaryen: the installed one, or the npm package throughnpx. Without either, the module is about 45% larger. - It writes
package.json.
The wasm-bindgen CLI must be the exact version in Cargo.lock, and the task checks this.
| File | Contents |
|---|---|
jlens.js | The ES module |
jlens.d.ts | TypeScript declarations |
jlens_bg.wasm | The WebAssembly module: about 1.2 MiB, 460 KiB gzipped |
package.json | npm metadata: "type": "module", exports, types |
A size budget for the gzipped module is enforced in xtask/src/wasm.rs.
Loading the module
Call init() once, and await it, before using anything else. By default it fetches
jlens_bg.wasm from next to jlens.js:
<script type="module">
import init, { Expression } from "./pkg/jlens.js";
await init();
</script>
Bundlers that understand new URL(..., import.meta.url) handle this as is. Vite, webpack 5,
Rollup and esbuild all do (esbuild with a loader for .wasm). Otherwise, pass the module’s URL
or its bytes:
// Vite: get the URL of the asset.
import wasmUrl from "jlens-wasm/jlens_bg.wasm?url";
await init({ module_or_path: wasmUrl });
// Node.js or Deno: pass the bytes.
import { readFile } from "node:fs/promises";
await init({ module_or_path: await readFile(new URL("./pkg/jlens_bg.wasm", import.meta.url)) });
initSync({ module }) instantiates synchronously from bytes or a WebAssembly.Module. This is
useful in workers.
Evaluating expressions
const expr = new Expression(source); // compile once (throws JlensError on syntax errors)
expr.evaluate(input, bindings, options); // evaluate many times
expr.evaluateJson(jsonText, bindings); // JSON text in, JSON text out
expr.free(); // release it (or let the garbage collector do so)
inputcan be any JSON-compatible value.undefined, or no argument, means there is no input document, so$is undefined. Values are read asJSON.stringifyreads them. As a result,Dateobjects become strings, and functions andundefinedfields are dropped. See how the input is read.bindingsis an object of variables and functions:{limit: 10}makes$limitavailable, and a function becomes a host function.optionssets the limits (timeout,maxDepth,maxStringLength,maxArrayLength,maxResultSize), and the clock and seed that make an evaluation reproducible:nowfixes$now()and$millis()(milliseconds since the epoch, aDateor an ISO 8601 timestamp), andseedfixes$random()and$shuffle().- The result is a plain value, or
undefinedwhen the expression evaluates to undefined. Numbers and object key order are the same as in jsonata-js.
evaluateJson takes the input as JSON text and returns the result as JSON text. For large
documents that you already have as text, such as a fetch response body, it avoids converting
the document to JavaScript objects and back.
How the input is read
evaluate reads its input during the evaluation, one object or array at a time, when the
expression first reaches it (ADR 0010). A path such as
customer.name reads two small objects, however large the rest of the document is. An object or
array of more than 16 values is read whole, with everything it holds, through JSON.stringify,
which is faster than reading its values one by one.
Two consequences follow:
- Errors appear when their part is read. A cycle, a
BigInt, or atoJSONmethod that throws makesJSON.stringifythrow.evaluatethrows the same exception, but only when the expression reads the object or array that holds it: an expression that never reaches it succeeds. - The input must not change during the evaluation. A host function that modifies the input object can change what the rest of the evaluation reads. Copy the input first if a function must modify it.
evaluateJson, evaluateWithAudit and Document convert their whole input first.
Reusing a document
evaluate reads the parts of the input that the expression reaches, on every call. For an
expression that reads most of a document of a few dozen kilobytes, that costs hundreds of
microseconds, often more than the evaluation itself. To evaluate several expressions against
one document (a set of rules, say), convert it once:
import { Document } from "jlens-wasm";
const doc = new Document(input); // or Document.fromJson(text)
const results = rules.map((rule) => rule.evaluateDocument(doc, bindings));
doc.free();
Evaluations never modify a document. Performance has measurements.
With explicit resource management, using expr = new Expression(...) frees the expression at
the end of the block.
Host functions
JavaScript functions, in the bindings or registered on an expression, are called from the expression like built-in functions:
const expr = new Expression("$shipping($sum(items.weight), region)");
expr.evaluate(order, { shipping: (weight, region) => weight * rates[region] });
// As in jsonata-js: available to every evaluation, with an optional signature.
expr.registerFunction("shipping", (weight, region) => weight * rates[region], "<ns:n>");
- Arguments and the result pass through JSON, like the input.
undefinedstands for undefined, and a function argument arrives as a JavaScript function that calls it (below). - A signature (
registerFunction’s third argument) checks the arguments before each call, with the errors of the built-in functions (T0410,T0412). A parameter with-receives the context value when the argument is missing:<n-:n>makesprice.$double()work. thisisundefined. Use a signature with-to receive the context value.- The function’s
lengthtells higher-order functions how many arguments to pass:$mappasses the index only to functions with two parameters or more. - An exception thrown by the function is rethrown by
evaluateas is. Itspositionandtokenare set to the call when it has none.evaluateWithAuditreports it with itscode, or itsname, and itsmessage. - Asynchronous functions are not supported. A function that returns a promise fails with
U1002. Fetch what the expression needs before evaluating it. - Precedence: a function in the bindings replaces a registered function of the same name, and both replace a built-in function.
The Rust crate guide describes the same model.
Functions as arguments
A host function can call a JSONata function it receives, such as a fallback or a step to retry: a lambda, a built-in function, a partial application, a chain or another host function.
const retry = (step, times) => {
for (let i = 1; ; i++) {
try {
return step(i);
} catch (e) {
if (i >= times) throw e;
}
}
};
new Expression("$retry(function($i) { $lookup(sku, $i) }, 3)").evaluate(order, { retry, lookup });
- The call is part of the evaluation, as when
$mapcalls its function. The context value isnull, and a lambda keeps the variables of its definition. The call counts as a level of depth, so a recursion through a host function ends withD1011; it checks the timeout; its result is limited bymaxResultSize. - It is synchronous: it returns the result, where jsonata-js returns a promise. Code written
for jsonata-js with
await step(i)keeps working. - Arguments and the result pass through JSON. A sequence comes back as an array or a single
value; a JavaScript function among the arguments becomes
undefined, and a function result comes back asundefined. - Its
lengthis 0 and itsarityproperty is the number of parameters, as in jsonata-js. - Errors are thrown as a
JlensErrorwith their codes, or as the exception a host function threw. The host function can catch them, or let them through, which ends the evaluation with that error. - It is valid until the host function returns. Called later, from a promise or a timer, it
throws
U1005.
Errors
Errors are Error objects named JlensError. They carry the JSONata error fields; every offset is
in UTF-16 code units, like JavaScript string indexes:
try {
new Expression("Account.Order[0;]");
} catch (err) {
if (err.name !== "JlensError") throw err;
err.code; // "S0202"
err.message; // 'Expected "]", got ";"'
err.position; // 16: where it was detected, as in jsonata-js
err.start; // 15: the offending source range
err.end; // 16
err.token; // ";"
}
Evaluation errors carry value too, when there is one (for T2001, the value that is not a
number). Invalid arguments, such as bindings that are not an object or malformed JSON for
evaluateJson, throw a TypeError instead. The error reference lists
every code.
Explaining results
evaluateWithAudit evaluates and records how the result was reached; see the
audit guide. Evaluation errors are reported in the result, not thrown:
const expr = new Expression('/** @label "Big order" */ $sum(items.(price * qty)) > $limit');
const { result, error, audit, explanation, explanationText } = expr.evaluateWithAudit(
{ items: [{ price: 2.5, qty: 4 }, { price: 1, qty: 3 }] },
{ limit: 20 },
{ level: "decisions", limits: { maxNodes: 1000 } },
);
result; // false
explanationText; // 'result: false\n✘ Big order — $sum(items.(price * qty)) > $limit → false (13 > 20)'
audit.root; // { kind: "binary", op: ">", label: "Big order", operands: [13, 20], outcome: "false", ... }
The options are those of evaluate, plus two more:
levelis"off","decisions"(the default) or"full".limitssets the capture limits:maxItems,maxString,maxDepth,maxRejected,maxNodesandmaxSource.
explanation.lines holds structured lines (depth, passed, text, message, node) for
rendering in a user interface. Audit node spans are UTF-16 offsets, so a viewer can highlight
a node’s source in the editor. The web editor example does this.
Tooling
parseRecover(source) never throws. It returns the partial syntax tree and every diagnostic:
parseRecover("Account.Order[0;].");
// { ast: {...}, diagnostics: [{ severity: "error", code: "S0202", message: ..., span: {start: 15, end: 16} }] }
expr.ast() returns the processed tree of a compiled expression, and functions() returns the
catalog of built-in functions. Spans are UTF-16 offsets throughout.
Schemas
inferSchema(samples, overrides?) infers a JSON Schema 2020-12 from sample documents. The
schema guide describes what it records and the overrides format:
const schema = inferSchema([doc1, doc2], {
"items[].type": { description: "Product category", enum: ["apparel", "electronic", "toy"] },
"items[].internalCode": { hidden: true },
});
The result is an ordinary JSON Schema object. Store it, edit it by hand, or pass it to
LanguageService.
The language service
const service = new LanguageService(schema, { limit: { type: "number" } });
const text = "items[type = 'a'].p";
service.complete(text, text.length);
// { range: {start: 18, end: 19},
// items: [{ label: "price", kind: "field", detail: "integer", documentation: "Examples: `1`", insertText: "price" }] }
service.diagnostics("customer.Login");
// [{ severity: "warning", code: "W0201", span: {start: 9, end: 14},
// message: 'Field "Login" is not in the schema; did you mean `login`?' }]
service.hover("customer.login", 10);
// { range: {start: 9, end: 14}, contents: '`login`: string\n\nExamples: `"1"`' }
service.signatureHelp("$substring(name, ", 17);
// { name: "$substring", signature: "<s-nn?:s>", params: ["$str", "$start", "$length"], activeParam: 1, ... }
schema is optional: without one, functions and variables are still completed. The second
argument declares the variables the expressions are evaluated with. Its keys are variable
names, with or without $, and its values are schemas, or null when the shape is unknown.
The third argument declares host functions, as {name, signature, params, summary} objects.
They are completed first, and hover and signature help show them:
new LanguageService(schema, null, [
{ name: "shipping", signature: "<ns:n>", params: ["weight", "region"], summary: "Shipping cost" },
]);
Create a new service when the schema, the variables or the functions change. Construction is cheap.
Completion items are ranked best first, and insertText replaces range. Field names that are
not plain identifiers come quoted with backquotes. documentation and hover contents are
Markdown.
CodeMirror 6
CodeMirror positions are UTF-16 offsets, like jlens offsets, so no conversion is needed.
examples/web-editor/jlens-codemirror.js in the repository
is a self-contained module to copy. It provides highlighting, completion, linting, hover and
signature help. The core of it is:
autocompletion({
override: [(context) => {
const { range, items } = service.complete(context.state.doc.toString(), context.pos);
return {
from: range.start, to: range.end, filter: false,
options: items.map((i) => ({ label: i.label, detail: i.detail, apply: i.insertText })),
};
}],
}),
linter((view) =>
service.diagnostics(view.state.doc.toString()).map((d) => ({
from: d.span.start, to: d.span.end, severity: d.severity, message: d.message,
})),
),
Monaco
Monaco works with line and column positions; convert with the model:
monaco.languages.registerCompletionItemProvider("jsonata", {
triggerCharacters: [".", "[", "$"],
provideCompletionItems(model, position) {
const { range, items } = service.complete(model.getValue(), model.getOffsetAt(position));
const start = model.getPositionAt(range.start);
const end = model.getPositionAt(range.end);
return {
suggestions: items.map((i) => ({
label: i.label,
insertText: i.insertText,
detail: i.detail,
documentation: i.documentation && { value: i.documentation },
kind: monaco.languages.CompletionItemKind.Field, // map i.kind as needed
range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column),
})),
};
},
});
// Diagnostics: monaco.editor.setModelMarkers(model, "jlens", ...) with the same conversion.
Limits and memory
| Limit | Default | Error |
|---|---|---|
Evaluation depth (maxDepth) | 500 | D1011 |
Evaluation time (timeout, milliseconds) | none | D1012 |
Length of a string built (maxStringLength, bytes of UTF-8) | 256 MiB | U1001 |
Items of an array built (maxArrayLength) | 16,777,216 | U1001 |
Size of the result as JSON text (maxResultSize, bytes) | 256 MiB | U1001 |
| Expression nesting | 1,000 levels | U1001 |
The size of the result counts a value each time it appears, as the JSON text does, and also
limits the arguments passed to a host function. Wasm evaluations are synchronous, so there is
no cancellation: use timeout. In a browser, an evaluation can also run in a Web Worker, and
terminating the worker stops it; the web editor does this.
The depth limit is lower than on native targets (50,000). WebAssembly runs on the JavaScript
engine’s stack, which is about 1 MiB in V8, and deep recursion beyond it raises a JavaScript
RangeError. Such an error, like any trap, leaves the module instance unusable. The defaults
keep evaluations inside the engine’s stack, with headroom. Raise maxDepth only if you have
measured what your target engines allow. The jlens errors themselves (JlensError,
TypeError) never affect the instance.
How deep an evaluation fits depends on the shape of the recursion and on V8’s compiler. V8 first runs the module with its baseline compiler (Liftoff), then recompiles the functions an evaluation uses often with its optimizing compiler (TurboFan), whose frames for jlens’s evaluator are about 1.6 times larger. Measured in Node.js 24 once the module is optimized, an audited recursion overflows V8’s stack near these depths (an unaudited one fits about 10% more):
| The recursive function’s body | Overflows near |
|---|---|
[$n, $n]^($f($n - 1)) (order-by) | 670 |
{'a': $f($n - 1)}, [$n]{'k': $f($n - 1)} (group) | 710 |
{'a': 1} ~> |$|{'b': $f($n - 1)}| (transform) | 720 |
[$n][$f($n - 1)] (filter) | 750 |
$f($n - 1) + 1, [$f($n - 1)] | 840 |
$n = 0 ? 0 : 1 + $f($n - 1) | 920 |
The default of 500 leaves room for shapes not measured here, and for engines with smaller stacks. The evaluator chapter has the details.
Expression, Document and LanguageService objects live in WebAssembly memory. The garbage collector
frees them through FinalizationRegistry. Call free(), or use using, to release them
promptly in loops.
TypeScript
The package ships jlens.d.ts. Every argument and result is typed: AuditResult, AuditTree,
AuditNode, Explanation, Diagnostic, Completions, CompletionItem, Hover,
SignatureHelp, FunctionInfo, JsonSchema, SchemaOverride, EvaluateOptions,
AuditOptions and JlensError. Results of evaluate are unknown; narrow them as usual.
The web editor example
examples/web-editor is a
complete page with no build step, published with this book as the
web editor:
- a JSONata editor (CodeMirror 6) with completion, diagnostics, hover and signature help, driven by a schema inferred from the input document;
- live evaluation, in a Web Worker (
worker.js), so that a slow expression never blocks typing; - the explanation and an audit tree viewer; clicking a node selects its source;
- the inferred schema;
- ready-made examples, including the samples of try.jsonata.org;
- a host function,
$try(host-functions.js), that calls the function it receives, with an example that uses it: the worker registers it on each expression, and the page declares it to the language service.
cargo xtask wasm
cargo xtask serve # then open the printed /examples/web-editor/ address
CodeMirror is loaded from esm.sh through an import map, so the page needs network access. The
page imports the package by its name, jlens-wasm, which the same import map resolves.
cargo xtask site builds the published site in target/site, the book with the editor in
editor/, and cargo xtask serve --site serves it.
Testing
cargo xtask wasm-test builds the package, then runs crates/jlens-wasm/tests/suite.mjs twice:
- in Node.js;
- in a headless Chrome, Chromium or Edge (set
JLENS_BROWSERto choose the executable).
--require-browser fails when no browser is found, instead of skipping. --example also
checks that the web editor loads and evaluates its sample, from the repository and as the site
serves it.
Audit and explainability
A JSONata expression returns a value. jlens can also tell you why it returned that value:
- which checks passed and which failed;
- what the compared values were;
- which items a filter rejected;
- which values were bound to variables.
The same evaluation, with the same result, produces an audit tree. An explanation is a readable summary of that tree.
A complete example
fixtures/shipping-rule.jsonata decides whether a store order gets free shipping.
Its conditions carry annotations:
$approved := (
/** @label "Authorized customer" @message "The customer login must be on the approved partners list." */
customer.login in ["alice.walker99", "bob.harrison42"]
and
/** @label "Minimum order grand total" @message "The order grand total must exceed 50,000.00." */
$total > 50000
and …
);
When a order grand totals only 37,000, jlens eval --explain prints:
$ jlens eval -f fixtures/shipping-rule.jsonata -i order.json --explain
result: {"action":"REGULAR_FEE","code":"SHIP_FEE_402","delivery":"SLOW"}
• $total := $sum(items.(unitPrice * quantity)) = 37000
• $electronics := $sum(items[type = 'electronic'].(unitPrice * quantity)) = 17000
• [type = 'electronic']: 2 of 3 items passed; rejected: {"code":"1","color":"blue",…}
• $blueTax := $sum(items[color = 'blue'].tax) = 1500
• $averageTax := $average(items.tax) = 2833.3333333333335
✘ $approved := ( customer.login in [...] and ... ) = false
✘ Minimum order grand total — $total > 50000 → false (37000 > 50000)
The order grand total must exceed 50,000.00.
• $approved ? { ... } : { ... } → {"action":"REGULAR_FEE",…} [else]
The explanation follows only the decisions that mattered. The and chain failed, so it shows
only the operand that failed, with its business message. The checks that passed are left out.
For an approved order, every check is listed with ✔.
In Rust
#![allow(unused)]
fn main() {
use jlens_core::audit::{AuditOptions, explain};
use jlens_core::{Bindings, Expression, Options};
let rule = Expression::compile(&rule_source)?;
let (result, audit) = rule.evaluate_audited(
Some(&document),
&Bindings::new(),
&Options::default(),
&AuditOptions::decisions(),
);
println!("{}", explain(&audit)); // text
let json = serde_json::to_string(&audit)?; // the full tree, e.g. to store with the decision
}
evaluate_audited returns the audit even when the evaluation fails. The error is in
audit.header.error, and on the node that raised it.
What is recorded
The audit tree mirrors the evaluation, not the syntax: a node evaluated three times (once per
item, or once per function call) appears three times. Each AuditNode has these fields:
| Field | Meaning |
|---|---|
node, kind, op, span, source | The syntax-tree node, its type, its operator or function, its location and text |
label, message, checkId | From the node’s annotation |
output, outcome | The value produced, and whether it was true, false, another value, undefined, or an error |
operands | For comparisons: the two values compared |
shortCircuited | For and/or: the right side was not evaluated |
branch | For conditionals: then, else or none |
binding | For $x := …: the variable bound |
filters | Predicate filters applied: items tested, items passed, and a sample of rejected items |
input | The context value (level full only) |
children | Recorded evaluations nested inside this one |
The header records:
- the engine version;
- FNV-1a hashes of the expression and of the input document, so you can tell later exactly what was evaluated;
- the evaluation timestamp (the value of
$now()) and its duration; - the result, or the error.
Levels
| Level | Records | Typical use |
|---|---|---|
decisions (default) | The root, plus nodes that decide: comparisons, and/or, conditionals, bindings, nodes producing booleans, nodes with filters, annotated nodes | Explanations, storing the reasons for a decision |
full | Every evaluated node, with its input | Debugging an expression |
off | Only the header | Recording what was evaluated and the result |
At level decisions, nodes that decide nothing are collapsed: their recorded descendants move
up to the nearest recorded ancestor. Inside a filter, the per-item evaluations are summarized
into the filter’s statistics instead of being recorded one by one.
Bounds
Audits of large documents stay bounded. CaptureLimits sets:
| Limit | Default | Effect |
|---|---|---|
max_items | 10 | Items (or object fields) kept per captured value; the rest becomes "… N more" |
max_string | 200 | Characters kept per string; longer strings end with … |
max_depth | 4 | Nesting depth kept in captured values |
max_rejected | 5 | Rejected items kept per filter |
max_nodes | 10,000 | Recorded nodes; beyond it, nodes are dropped and truncated is set |
max_source | 200 | Characters of source text kept per node |
Event logs and custom tracers
Auditing is built on a lower-level interface, the Tracer trait. The evaluator calls a
tracer’s methods as it goes:
enter/exitfor each node;operandsfor each comparison;short_circuit,branchandbind;filter_begin,filter_itemandfilter_endfor filters.
You can implement your own tracer, for example to collect metrics, and pass it to
Expression::evaluate_traced. jlens provides three:
| Tracer | Output |
|---|---|
AuditCollector | The AuditTree described above |
JsonlTracer | One JSON object per event (jlens eval --trace-log events.jsonl) |
TracingTracer (feature tracing) | Events on the tracing target jlens::eval: entries and exits at TRACE, decisions at DEBUG |
Performance
When no tracer is attached, auditing costs one check per evaluated node. Compared with the
evaluator before tracing existed, interleaved benchmark runs measured between −5.7% and +2.7%
across five workloads, within the noise of the machine. With a tracer attached, evaluation does
more work in proportion to what is recorded. Level decisions is the cheapest useful level:
for the shipping rule on a 200-item document, it takes about three times as long as an
evaluation without a tracer, and full about fifteen times. cargo bench -p jlens-core -- audit
measures this; see Performance.
Limitations
- Node spans refer to the expression’s own source. For expressions evaluated through
$eval, and for the internal helper behind function composition ($f ~> $g), the reported source snippets refer to a different text. - The audit captures values as JSON. Functions appear as
"<function>", and undefined values are omitted.
Schema and autocomplete
jlens can learn the structure of your documents from samples and use it to help people write expressions. The language service then provides:
- completion of field names that knows which object you are in;
- warnings for fields that do not exist;
- hover documentation;
- signature help for functions.
The jlens-schema crate provides both parts, and the CLI and the WebAssembly package expose
them.
1. Infer a schema from samples
$ jlens schema infer samples/*.json -o order.schema.json
inferred from 120 sample(s): order.schema.json
Glob patterns are expanded by jlens itself, so they also work in Windows shells. With --each,
a sample file holding a top-level array counts as one sample per item.
Inference records, for every place in the documents:
| What | How |
|---|---|
| Types | Every JSON type seen. integer is used while all numbers are written without a fraction; 1500.0 makes it number |
| Fields | In first-seen order, with required when every object had the field, and the fraction of objects that had it otherwise |
| Array items | The merged shape of all items of all arrays seen at that place |
| Numbers | Minimum and maximum |
| String formats | date-time, date, time, email, uuid or uri, when every string seen has that format |
| Enumerations | When the values come from a small set (at most 10) and repeat |
| Examples | Up to three distinct values |
The result is a standard JSON Schema 2020-12 document. Fields that are not required carry
x-jlens-presence, the fraction of objects that had them. You can commit the file, review it,
and edit it like any JSON Schema. jlens also reads JSON Schemas written by hand or by other
tools: it supports $ref to local definitions, and it merges anyOf, oneOf and allOf.
2. Describe the fields
Samples tell jlens what fields exist, but not what they mean. Overrides add that information.
An overrides file maps paths to settings; [] steps into array items:
{
"items[].type": {
"description": "Product category used for catalog classification",
"enum": ["apparel", "electronic", "toy"]
},
"items[].tax": { "label": "Import tax" },
"customer.login": { "description": "Login chosen at sign-up" },
"internalRevision": { "hidden": true }
}
| Setting | Effect |
|---|---|
description | Shown in completion and hover |
label | A display name, shown in bold |
enum | The allowed values |
examples | Example values |
format | A format name |
type | Replaces the inferred types, e.g. ["string", "null"] |
hidden | Never suggested by completion |
$ jlens schema infer samples/*.json --overrides overrides.json -o order.schema.json
A path that does not exist yet is created, so overrides can also declare fields that are absent from the samples.
3. Use it
In the terminal
$ jlens check --schema order.schema.json -f rule.jsonata
warning[W0201]: Field "unitprice" is not in the schema; did you mean `unitPrice`?
--> rule.jsonata:4:22
|
4 | $sum(items[type = 'x'].unitprice)
| ^^^^^^^^^
$ jlens schema complete --schema order.schema.json "items[type = 'electronic']."
code field string
color field string
type field string
unitPrice field number
quantity field integer
tax field number
--cursor N sets the cursor position in characters, and --json prints the full completion
items.
In Rust
#![allow(unused)]
fn main() {
use jlens_schema::{FunctionDecl, LanguageService, Schema};
let schema = Schema::from_json_schema(&serde_json::from_str(&schema_text)?)?;
let service = LanguageService::new(Some(schema))
.with_variable("approved", None) // variables the rule is evaluated with
.with_function( // and its host functions
FunctionDecl::new("shipping", "Shipping cost of a weight to a region")
.signature("<ns?:n>")
.params(["weight", "region"]),
);
let completions = service.complete(text, cursor_byte_offset);
let diagnostics = service.diagnostics(text);
let hover = service.hover(text, cursor_byte_offset);
let help = service.signature_help(text, cursor_byte_offset);
}
Declare the host functions the expressions are evaluated with: they are completed first, and hover and signature help show their signature, parameters and summary. A declared function replaces a built-in function of the same name, as it does during evaluation.
All offsets and spans in the Rust API are byte offsets into the expression text. The WebAssembly package converts them to and from the UTF-16 offsets that browsers and editors use.
In the browser
The WebAssembly package exposes the same service for editors such as CodeMirror and Monaco; see the WebAssembly guide.
What completion understands
Completion knows the context at the cursor, that is, which object the next field name will be looked up in. It follows the evaluator’s rules:
| You type | Suggested |
|---|---|
items. or items[...]. | Fields of an item (arrays are flattened, as during evaluation) |
items[ … ] | Inside the predicate: fields of an item |
items{ … } / items^( … ) | Fields of an item, in group-by keys and values and in sort keys |
$sum(items.( … )) | Fields of an item, inside a block applied to each item |
($i := items[0]; $i. | Variables keep the type of what was assigned to them |
items@$it. … $it. | Focus variables bind the items of their step |
$map(items, function($i){ $i. | Callback parameters of $map, $filter, $single, $sort and $reduce get the item type |
$sort(items, …)[0]. | Functions that return their input ($sort, $filter, $reverse, …) keep its type |
items.`ty | Field names that need backquotes |
$su | Variables in scope, then declared host functions, then built-in functions |
customer.login (after a space) | Operators: and, or, in |
Each field suggestion carries:
- its type (
string,array<object>,integer | null, …); - its label and description;
- its presence, for optional fields;
- its allowed values or examples.
Field names that are not plain identifiers are inserted with backquotes, and hidden fields are never suggested.
Completion works on incomplete expressions too, such as $count(items[ or
items[type = 'x' and . That is the normal state of an expression while someone types it.
Diagnostics
LanguageService::diagnostics returns:
- syntax errors, from the recovering parser;
- annotation warnings;
W0201warnings for field names the schema does not have, with a “did you mean” suggestion when a field name is close;W0202warnings for calls of unknown functions, with the same kind of suggestion, andW0203warnings for calls with too few or too many arguments for the function’s signature. These need no schema. A function is known when it is built in, declared withLanguageService::with_function(or injlens.json), or bound anywhere in the expression; a variable declared withwith_variablemay hold a function, so its calls are not checked;W0205warnings for arguments whose type the function’s signature cannot accept, such as$sum(items.name)whennameis a string. Only types that are known count: literals, the results of operators, and fields of the schema. A field that is absent passes any signature; the warning means that the call fails whenever the field is present;W0204warnings for variables that are not declared, with a suggestion, when at least one variable is declared withwith_variable(or injlens.json): the declarations are then taken to be complete. Names bound in the expression, and functions, are known.
Missing fields are reported only where the context is fully known. Nothing is reported after a
user-defined function, an object constructor or $lookup, so the warnings do not produce false
alarms. Schemas inferred from samples can miss optional fields that the samples did not contain.
Add those fields with overrides, or treat W0201 as advice.
Editors (language server)
jlens lsp is a language server: editors that speak the
Language Server Protocol get, in
JSONata files:
- completion of fields, variables, and built-in and host functions;
- diagnostics: syntax errors, annotation problems, and fields that the schema does not have, with a “did you mean”;
- hover, with the type and description of fields and the documentation of functions;
- signature help in function calls.
It is the language service of the web editor, behind a standard protocol. Install it with the CLI:
cargo install jlens-cli
jlens lsp # what editors run; it speaks LSP on standard input and output
Configuration: jlens.json
A jlens.json at the root of a workspace folder says which schema, variables and
host functions apply to which expression files:
{
"rules/**/*.jsonata": {
"schema": "schemas/order.schema.json",
"variables": {"minimum": {"type": "number"}, "region": null},
"functions": [
{"name": "shipping", "signature": "<ns:n>", "params": ["weight", "region"],
"summary": "Shipping cost of a weight to a region"}
]
},
"**/*.jsonata": {}
}
- Keys are glob patterns, matched against the file’s path relative to the folder of
jlens.json.*stays within a directory and**crosses directories. The first matching pattern applies; files that no pattern matches get completion and diagnostics without a schema. schemais a JSON Schema of the input documents, relative to the folder ofjlens.json.jlens schema infercreates one from sample documents; see the schema guide.variablesdeclares the variables the expressions are evaluated with, with a JSON Schema of each value, ornullwhen the shape is unknown. Names can start with$.functionsdeclares the host functions:name, and optionallysignature,paramsandsummary.
All settings are optional. When jlens.json or a JSON file changes, the server reloads the
configuration and the schemas, and checks the open files again. It relies on the editor’s file
watching, or on the file being saved in the editor. A configuration that cannot be read is
reported as a message, and its files are checked without settings.
The client can also pass the same object as initializationOptions, relative to the first
workspace folder. Its patterns come before those of jlens.json.
jlens check reads the same file, so that CI reports what the
editor shows.
Neovim
With Neovim 0.11 or later, in init.lua:
vim.filetype.add({ extension = { jsonata = "jsonata" } })
vim.lsp.config("jlens", {
cmd = { "jlens", "lsp" },
filetypes = { "jsonata" },
root_markers = { "jlens.json", ".git" },
})
vim.lsp.enable("jlens")
Helix
In languages.toml:
[[language]]
name = "jsonata"
scope = "source.jsonata"
file-types = ["jsonata"]
roots = ["jlens.json"]
language-servers = ["jlens"]
[language-server.jlens]
command = "jlens"
args = ["lsp"]
Emacs
With Eglot (built into Emacs 29):
(define-derived-mode jsonata-mode prog-mode "JSONata")
(add-to-list 'auto-mode-alist '("\.jsonata\'" . jsonata-mode))
(with-eval-after-load 'eglot
(add-to-list 'eglot-server-programs '(jsonata-mode "jlens" "lsp")))
Visual Studio Code
The jlens extension (thkprado.jlens, from 0.15) highlights JSONata and starts jlens lsp for
the jsonata language, in files ending in .jsonata. It looks for the server in this order:
- the setting
jlens.server.path, the path of ajlensprogram; - the
jlensbinary bundled with the extension, in its packages for Linux, macOS and Windows on x86-64, and Linux and macOS on ARM64; jlenson thePATH, on other platforms.
When it finds none, it says so, and highlighting keeps working. jlens.server.path is read
from user settings only, never from a workspace’s: a repository cannot choose which program
runs. The command jlens: Restart Language Server starts the server again, after an update
of jlens for example, and changing the setting does too.
The jlens output channel shows the server’s version and path, and its messages. The setting
jlens.trace.server (messages or verbose) adds the protocol’s messages to it.
The extension also checks jlens.json as it is edited, with a JSON Schema: unknown settings,
misspelled ones and wrong types are underlined, and hovers describe each setting.
If another JSONata extension is installed, VS Code uses both: disable the other’s language features, or you get the diagnostics of both. In the browser, the WebAssembly package offers the same service to CodeMirror and Monaco.
Positions
The server counts positions in UTF-16 code units, the protocol’s default, or in bytes when the
client offers UTF-8 (general.positionEncodings). Documents are synchronized incrementally.
Positions past the end of a line or of the document are clamped to it, as the protocol requires.
Errors
A bug in jlens that panics while the server handles a request does not stop the server: the
request is answered with an InternalError, and a panic while it handles a notification (such
as checking a document after a change) is logged with window/logMessage. The panic itself is
printed on the standard error, which editors keep in the server’s log. Please report it, with
the text of the document.
Architecture overview
┌───────────────────────── jlens-core ──────────────────────────┐
source ──► lexer ──► Pratt parser ──► raw tree ──► process ──► Ast (Expression) │
│ (recover mode, annotations) (paths, stages, %, │
│ tail calls) │ │
│ ▼ │
input JSON ──► Value / Document ───────────────────────────────────► evaluator │
│ function library ──┘ │ │
│ Tracer events ◄───────┘ │
│ │ │
│ AuditCollector ◄──┴──► JsonlTracer, ... │
└──────────────────────────┬───────────────────────────────────┘
│ result, AuditTree, Explanation
jlens-schema: Schema (inference, JSON Schema) ─► typing ─► LanguageService
jlens-cli: the `jlens` binary jlens-wasm: the ES module for browsers
Crates
| Crate | Contents | Depends on |
|---|---|---|
jlens-core | Lexer, parser, syntax tree, evaluator, function library, errors, audit subsystem | serde_json, indexmap, jlens-regress (a fork of regress), ryu-js |
jlens-schema | Structural metadata (Schema), inference, JSON Schema import and export, static typing of expressions, the language service | jlens-core |
jlens-cli | The jlens command: eval, check, functions, schema, ast | jlens-core, jlens-schema, clap |
jlens-wasm | WebAssembly bindings: Expression, Document, LanguageService, … with TypeScript declarations | jlens-core, jlens-schema, wasm-bindgen |
xtask | Repository automation: conformance, generated docs, doc checks, the Wasm package | jlens-core |
The core crate has no dependency on the others. Everything a tool needs from the language is public there:
- the syntax tree, with node ids and spans;
- recovering parsing;
- the function catalog;
- the tracer events.
From source to result
- Lexing and parsing (Parsing). The lexer produces tokens with byte spans and collects documentation comments. A Pratt parser, ported from jsonata-js, builds a raw tree. In recover mode it records errors and keeps going, which editors need. Annotations are attached to the subexpression that follows them.
- Processing. The raw tree becomes the
Ast:- path steps and their stages (filters, sorting,
#,@) are laid out; - parent references (
%) are resolved to ancestor slots; - tail calls are marked. The result is immutable and shared by all evaluations of the expression.
- path steps and their stages (filters, sorting,
- Evaluation (Evaluation). A tree-walking interpreter over the
Valuemodel. Values are reference counted, with sequences flagged as in jsonata-js; see ADR 0003. Functions are values. Tail calls run in a trampoline. Guardrails bound the depth and the time. - Tracing. When a tracer is attached, the evaluator reports events: entering and leaving a
node, operands, short circuits, branches, bindings and filters. The audit collector turns
them into an
AuditTree, andexplainsummarizes the tree. See ADR 0004.
Tooling
jlens-schema builds on recovering parsing (Schema and language service):
Schemarecords the shape of sample documents and round-trips through JSON Schema.- The typing walker follows an expression the way the evaluator would. It computes what each part of the expression refers to in the schema: paths, filters, variables and function results.
- The
LanguageServiceanswers completion, diagnostics, hover and signature help from that information. A placeholder is substituted at the cursor so that incomplete expressions parse.
The CLI and the WebAssembly package are thin layers over these crates. The WebAssembly package converts offsets between UTF-8 bytes (Rust) and UTF-16 code units (JavaScript) at the boundary.
Quality
- Conformance testing: the official JSONata test suite runs on every
cargo test, against a baseline that may only improve. Property tests check that nothing panics. - Performance: criterion benchmarks and a comparison with jsonata-js.
- Documentation checks:
cargo xtask doc-checkruns every example in this book. The generated pages (functions, errors) are checked against the code.
The design decisions are recorded in the decision records.
Parsing
Parsing turns source text into the tree the evaluator walks. It has three stages, each ported
from src/parser.js in jsonata-js:
source ──► lexer ──tokens──► Pratt parser ──raw tree──► process ──► Ast
(lexer.rs) (parser.rs) (process.rs) (ast.rs)
| Entry point | Behaviour |
|---|---|
jlens_core::parse(src) | Stops at the first error and returns it |
jlens_core::parse_recover(src) | Never fails. Errors become Error nodes plus diagnostics |
Expression::compile(src) | parse, then wraps the tree for evaluation |
jlens_core::debug::{tokens, raw_tree} | Inspection, used by jlens ast --tokens/--raw |
Lexer
The lexer produces one token at a time, on demand, because one character is ambiguous: /
starts a regular expression in operand position (/ab+c/i) and means division in operator
position (a / b). The parser passes an infix flag with each request.
Token kinds are names, variables ($x), strings, numbers, the literals true, false and
null, regexes and operators. and, or and in are lexed as operators; the parser turns them
back into names when they appear where an operand is expected, as in and.or.
Differences from the reference lexer:
- Spans. Every token records its full byte range. The reference implementation only records the position just after a token.
- Doc-comments.
/** ... */comments are kept and attached to the next token, for annotations. Ordinary comments are skipped as before. - Surrogates.
𝄞-style escape pairs are combined into one character. A lone surrogate becomes U+FFFD, because Rust strings cannot hold one.
Pratt parser
JSONata’s grammar is mostly operators, and a Pratt parser (top-down operator precedence) handles it with a table of binding powers:
| Binding power | Operators |
|---|---|
| 80 | [ (filter), ( (call), @, # |
| 75 | . |
| 70 | { (group-by), unary - operand |
| 60 | * / % |
| 50 | + - & |
| 40 | = != < <= > >= in ^ ~> ?: ?? |
| 30 | and |
| 25 | or |
| 20 | ? |
| 10 | := |
Each token can define two parse functions:
nud, which parses the token in prefix position, such as a literal,-x,(block),[array]or{object};led, which parses it in infix position, such asa + b,a[...],f(...)ora{...}.
The core loop is short:
expression(rbp):
t = current token; advance
left = t.nud()
while rbp < binding power of current token:
t = current token; advance
left = t.led(left)
return left
A few constructs are desugared while parsing:
x ?? yandx ?: ybecome a fallback node (NodeKind::Fallback) that evaluatesxonce, then keeps its value if it exists (??) or is true as a Boolean (?:). jsonata-js desugars them into the conditions$exists(x) ? x : yandx ? x : y, with a copy ofx: nesting them doubles the tree at each level;function($a){...}is recognised as a lambda when the callee of a call is the namefunctionorλ.
Annotations and binding power
When the first token of an expression carries a doc-comment, the loop above runs in two phases.
First it parses with binding power max(rbp, 30), which stops at and or or, and attaches the
annotation to that result. Then it continues with the original rbp. Pratt parsing builds the
same tree regardless of where the loop is split, so the annotation marks a subtree without
changing the shape of the tree. The test annotations_never_change_the_tree checks this
property.
Post-processing
process.rs is the port of processAST. It turns the raw tree into the evaluator’s tree:
- Paths. Nested
.operators become onePathnode with a flat list of steps. String steps become names, and number or boolean steps are rejected (S0213). - Predicates.
step[expr]becomes a filter stage on the step. On a non-path expression such as$var[0], it becomes a predicate that applies to the whole result. - Bindings and clauses.
@$vand#$isetfocusandindexon the step and switch it to tuple mode.^(...)becomes aSortstep, and{...}after a step becomes itsgroup. - The parent operator
%. Each%gets an ancestor slot. The processor walks back through the enclosing path to the step whose input is the wanted ancestor, and marks it to bind that slot’s label (!0,!1, …). That step and the steps after it run as a tuple stream. Slots live inAst::slots, an arena, because the reference implementation shares and mutates slot objects across nodes. - Tail calls. A function call in tail position of a lambda (either branch of a conditional, or the last expression of a block) is wrapped in a thunk. The evaluator’s trampoline runs thunks in a loop, so tail-recursive functions don’t grow the stack.
Finally, nodes receive sequential NodeIds in pre-order. Audit trees will refer to nodes by
these ids.
Error recovery
In recover mode, an error does not abort parsing. The behaviour follows jsonata-js:
- the error is recorded;
- the rest of the input is consumed;
- the current token becomes an error marker with binding power 10, which wraps whatever was parsed before it.
So Account.Order[0;].Product yields path(Account, Order[0]) plus error S0202.
jlens recovers in more cases than jsonata-js:
- Lexer errors, such as an unterminated string while the user is typing, are recovered.
- Post-processing errors (
S0209,S0210,S0213,S0215–S0217) become diagnostics and error nodes. - An expression that starts at the error marker reuses the recorded error, instead of reporting
an extra
S0211for anundefinedtoken.
The seven recovery cases from jsonata-js’s test/parser-recovery.js are ported in
crates/jlens-core/tests/parser.rs. They check the same error codes, positions and partial
trees. The same test file also parses every expression of the official suite: all valid
expressions must parse, and all invalid ones must fail with the expected S0xxx code.
Evaluation
The evaluator (crates/jlens-core/src/eval.rs) is a tree-walking interpreter over the
processed syntax tree. It is a function-by-function port of src/jsonata.js in
jsonata-js. That code effectively defines JSONata’s subtler behaviour, such as when a result
is a sequence or an array, so jlens keeps its structure recognisable. Unlike jsonata-js,
evaluation is synchronous.
Expression::evaluate(input, bindings)
└─ eval::evaluate converts input JSON, wraps an input array, binds $$ and bindings
└─ Evaluator::eval one call per node: dispatch, predicates, group-by, sequence unwrapping
├─ path / step / tuple_step / filter / stages location paths
├─ binary / array_constructor / group / sort operators and constructors
└─ call / apply / partial / apply_expression functions
Values
See ADR 0003 for the reasoning. The short version:
Value | Notes |
|---|---|
Undefined | Never escapes the API: results become Option<serde_json::Value> |
Null, Bool, Number(f64), String(Rc<str>) | Numbers are IEEE doubles, as in JavaScript |
Array(Rc<Array>) | Items plus flags: SEQUENCE, KEEP_SINGLETON, OUTER_WRAPPER, CONS, TUPLE_STREAM |
Object(Rc<RefCell<Map>>) | Insertion-ordered. Shared and mutable because transforms update matches in place |
Function(Rc<Function>) | A lambda closure, a built-in, a host function, a partial application, a transformer, or a regex |
Sequences
A sequence is the result of navigating a path. After evaluating any node, a sequence of
length 0 becomes undefined, and a sequence of length 1 becomes its only item, unless
KEEP_SINGLETON is set by the [] modifier. Arrays that come from the input, and arrays built
with [...], are not sequences, so they never collapse. This single rule, applied at the end of
Evaluator::eval, is why Account.Order.Product.Price returns a number for one product and an
array for several.
When the input document is itself an array, it is wrapped in a one-item sequence flagged
OUTER_WRAPPER, so that the array counts as a single context value. $ unwraps it again.
Paths and tuple streams
path evaluates its steps left to right. Each step maps over the current sequence and flattens
the results by one level. Array constructors flagged CONS are not flattened.
Steps that bind variables switch the path into tuple-stream mode from that step on. These are
@$focus, #$index, and steps marked as the ancestor for a %. In this mode each item is
carried as a tuple, an object holding @ (the current value) and the bound variables. Later
steps, predicates, sort keys and group-by expressions are evaluated in a frame created from the
tuple. At the end of the path the tuples are mapped back to their @ values, unless an
enclosing path still needs the ancestry.
Functions and the trampoline
A lambda value captures its definition (Arc<LambdaDef>, shared with the tree), the
environment frame in which it was created, and the context value. Applying it:
- validates the arguments against the lambda’s signature, if it has one;
- creates a child frame of the captured environment and binds the parameters (missing ones are bound to undefined, so they shadow outer variables);
- evaluates the body.
When post-processing marked a call as being in tail position, the body evaluates to a
thunk instead of making the call. Evaluator::apply runs thunks in a loop, so tail-recursive
functions run in constant stack depth. $loop(100000, 0) in the test suite exercises this.
Built-in functions are Native entries in functions/mod.rs, each with its signature and declared
parameters. Host functions (HostFunction, from the bindings) have an optional signature and an
arity; their arguments and results are converted to and from JSON
(ADR 0006). Partial application ($f(?, 2)) works for lambdas,
built-ins and host functions.
Signature validation
Signatures such as <s-nn?:s> are checked by turning the arguments into a string of type
symbols (s, n, b, l, a, o, f, or m for undefined). That string is matched
against the parameter patterns by a small greedy backtracking matcher. The matcher is equivalent
to the regular expression jsonata-js builds, but needs no regex engine. The match then:
- fills in the context value for
-parameters (T0411if its type is wrong); - wraps single values for
aparameters; - checks array subtypes (
T0412).
Environments and memory
Frames form a chain from the innermost scope to the root; the built-ins sit behind the root. A
closure stored in the frame that it captured creates a reference cycle, as in
$f := function(){ $f() }. Reference counting cannot reclaim such a cycle on its own. The
evaluator therefore records every frame that receives a function, array or object binding, and
clears those frames when the evaluation ends.
Guardrails
| Limit | Option | Error | Default |
|---|---|---|---|
| Evaluation depth | Options::max_depth | D1011 | 50,000 native, 500 in Wasm |
| Wall-clock time | Options::timeout | D1012 | none |
| Cancellation from another thread | Options::cancel_token | U1003 | none |
| Expression nesting | (fixed) | U1001 | 1,000 levels of syntax tree, checked by the parser |
| Length of a built string | Options::max_string_length | U1001 | 256 MiB (in bytes of UTF-8) |
| Items of a built array | Options::max_array_length | U1001 | 16,777,216 |
| Size of the result, and of the arguments of a host function | Options::max_result_size | U1001 | 256 MiB (in bytes of compact JSON) |
jsonata-js has no depth limit of its own; it stops when the JavaScript stack overflows. jlens needs limits so that it fails with an error instead of crashing the process.
- Native targets. The parser, the post-processing pass, the evaluator and the schema typing
walker grow their stack on demand (with the
stackercrate), so the default depth limit is generous. The CLI also runs on a thread with a 64 MiB stack, which covers the other recursive walks of the tree (printing it as JSON, for example). - WebAssembly. The stack cannot grow, and an overflow traps, which leaves the module
instance unusable. jlens links the module with a 4 MiB stack and checks the space left before
each recursive step (
jlens_core::stack), failing withD1011(evaluation) orU1001(parsing) instead. WebAssembly frames also use the JavaScript engine’s own stack, which is about 1 MiB in V8 and cannot be measured from inside the module; the lower default depth limit (500) keeps evaluations inside it. The WebAssembly guide has the measurements behind it. A largermaxDepthcan raise a JavaScriptRangeErrorand break the instance. - Calls from native functions. A lambda whose body is a tail call leaves no expression on
the stack, so a recursion through a higher-order function
(
function($n){ $map([$n], function($v){ $f($v) }) }) deepens only through the native function’s calls back. Each of those calls counts as a level of depth and checks the stack too.
The nesting limit bounds every recursive walk of the syntax tree, including the ones that do
not grow the stack, such as cloning and dropping it. Both nested subexpressions and the operators
of a chain count: 1 + 2 + 3 is a tree two levels deep, built by a loop rather than by
recursion. 1,000 levels is far more than any hand-written expression uses.
The size limits stop an expression that doubles a string or an array on each step, or a path
whose steps multiply the length of a sequence, before it runs out of memory, which would abort
the process. They are checked by the operators that build strings and arrays (&, ranges,
array constructors, path steps) and on the value that each built-in or host function returns;
$join checks the length before joining. The operators check before they build the value; a
function’s result is checked once it has returned. * and **, which flatten nested arrays,
count the items as they go.
The string and array limits apply to each string and array on its own, and values are shared rather than copied: an object bound to a variable can be nested in a new object twice, then that object twice, and so on. Thirty such steps stand for about a billion copies of the first object while using little memory, until something writes them all out. So everything that writes a value out is limited too:
- The result, which becomes JSON when the evaluation ends, is measured first: its size is
the length of its compact JSON text, counting a shared value each time it appears. The walk
stops once the size passes
Options::max_result_size, and the evaluation fails withU1001, before anything is converted. Escaped characters count as one byte, so the size is slightly below the length of the text thatformat_jsonprints. - The arguments of a host function are measured together in the same way, and so is its
context value (
HostCall::input). $string,$clone(and the transform operator, which clones its input) and&write JSON text, which is a string: the text is checked againstOptions::max_string_lengthas it is written.- The
valueof an error (T2001,T0410and others) is left out when it is larger than the default size of a result.
Tracing hooks
The evaluator reports its work to an optional Tracer; ADR 0004
records why. The hooks sit at these points:
| Hook | Where |
|---|---|
enter / exit | Evaluator::eval, around every node, including errors |
operands | binary, before a comparison or in |
short_circuit | binary, when and/or skips its right side |
branch | the conditional, after the condition |
bind | :=, before binding |
filter_begin / filter_item / filter_end | filter, around expression predicates (numeric indexes are not reported) |
When no tracer is attached, eval calls the untraced code directly. The traced variant is a
separate function marked cold, so the hot path does not change. The
audit guide describes the tracers built on these hooks.
Schema inference and the language service
The jlens-schema crate has four modules:
| Module | Role |
|---|---|
shape.rs | The Shape model, incremental inference, overrides |
jsonschema.rs | Conversion to and from JSON Schema 2020-12 |
typing.rs | Static typing of expressions against shapes |
service.rs | Completion, diagnostics, hover and signature help |
Inference
A Shape describes all the values found at one place in the documents. Shape::observe folds
one more value into it:
- Types. Each value adds its JSON type. A number written without a fraction is an
integer; one with a fraction or an exponent turns the shape intonumber. Thefloat_roundtripfeature ofserde_jsonpreserves that distinction. - Objects. Every field gets a sub-shape, and every object observed increments a counter.
requiredisfield observations == objects observed, andpresenceis the ratio of the two. - Arrays. All items of all arrays observed here fold into one
itemsshape. - Scalars:
- numbers update the minimum and the maximum;
- strings narrow a common format;
- up to ten distinct scalar values are tracked. They become an enumeration once at least two distinct values exist and some value repeats, which keeps unique identifiers out of enumerations.
Inference never looks back at earlier samples, so Schema::observe can process a stream of
documents in constant memory per distinct field.
Static typing
A type Ty is a set of shapes plus a partial flag. The flag means that something unknown
contributed: a user function, an object constructor, or a missing schema. Walker::walk
computes the type of each node and applies the evaluator’s context rules:
- A path evaluates each step against the elements of the previous step’s result. Elements are the shapes with arrays replaced by their items, recursively, which is the flattening the evaluator applies.
- Stages of a step (predicates and index bindings) see the step’s elements. A group-by sees the elements of the path’s result.
@$varbinds the step’s elements and leaves the context unchanged.#$ibinds a number.$x := ebindsxto the type ofeuntil the end of the enclosing block.- Lambdas are walked with their parameters bound to unknown types. When a lambda is the
callback of
$map,$filter,$single,$sortor$reduce, its parameters get the element type of the array argument. - Calls to built-ins that return their first argument (
$sort,$filter,$reverse,$distinct,$shuffle,$single,$clone,$sift) keep its type.$appendreturns the union of its arguments’ types. Other built-ins return scalars, except those whose structure is opaque, such as$mapand$lookup, which return unknown types. A name bound outside the expression (a declared variable or host function) is not a built-in, and its calls return unknown types.
While walking, the walker reports events to a visitor:
- a field reference, with the context it is looked up in;
- a variable reference, with its type and the scope.
The services are all visitors over the same walk.
Completion
Completion has to work on text that does not parse yet, so it proceeds in four steps:
- Lexical check. If the cursor is inside a string or a comment, nothing is suggested. If it is inside a backquoted name, a quoted field name is completed.
- The word at the cursor becomes the replacement range. If the previous token ends an
operand (a name, a literal, or a closing bracket) and is not
and,ororin, and there is whitespace before the cursor, operators are suggested instead. - The placeholder trick. The word is replaced with a sentinel identifier
(
__jlens_cursor__, or$__jlens_cursor__for variables), and the text is parsed with error recovery. The parse succeeds whenever the expression before the cursor is well formed, because the sentinel completes it. Anything broken after the cursor becomes an error node. - The walk finds the sentinel’s event. For a field, it yields the context type, whose fields are the suggestions. It also yields the scope, whose variables are suggested with the declared host functions and the built-in functions.
The two field paths that matter most, items. and items[, are therefore handled by the same
machinery as $map(items, function($i){ $i..
Diagnostics, hover and signature help
- Diagnostics combine the recovering parser’s errors and annotation warnings with
W0201for every field reference whose context is fully known (Ty::is_known_objects) but lacks the field. A Levenshtein distance of at most 2 picks the “did you mean” suggestion. The walk also reports every call of a$nameand every name it binds (:=, parameters,@,#); the calls are checked after the walk, because a function may call a name bound after it, or itself. Calls of unknown names getW0202, and calls whose argument count the signature rejects getW0203: parameters marked?or-are optional, and+accepts any number. - The call events carry the type of each argument. Literals and operators have a JSON type
(
Ty::literal), schema fields have theirs, and anything else is unknown.Ty::symbolsturns a fully known type into signature symbols, keeping the items of arrays apart, and the signature is matched as jsonata-js matches it (greedy, with backtracking), except that an argument whose type is unknown matches any parameter. A path may yield a single item of an array, so an array also matches what its items match. When no match exists,W0205names the argument jsonata-js would report inT0410orT0412. Undefined is left out: a field that is absent passes any signature, but one that is present cannot. - When variables are declared, the declarations are taken to be complete, and references to
names that are neither declared, bound anywhere in the expression, nor functions get
W0204. - Hover keeps the innermost field or variable event whose span contains the offset, and renders its type and documentation as Markdown.
- Signature help scans backwards from the cursor for the unmatched
(of a call, ignoring strings and comments and balancing brackets. It counts the top-level commas to find the active parameter, and looks the function up among the declared host functions, then in the built-in catalog.
Roadmap
How 0.1 was built
jlens was built in phases. A phase was complete only when its documentation was written.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 0 | Workspace, CI, documentation skeleton, conformance harness | CI green; conformance report | done |
| 1 | Lexer, Pratt parser, AST post-processing, spans, recover mode, annotations; jlens ast / jlens check | Syntax groups of the suite and the parser-recovery cases pass | done |
| 2 | Value model and evaluator (paths, sequences, predicates, operators, lambdas, closures, tail calls, sort, group-by, parent operator, transform, limits) | Suite groups that don’t depend on functions pass | done |
| 3 | Function library, signatures, regex, date/time and number formatting | 100% of the suite minus documented overrides | done |
| 4 | Evaluation logging and auditing (tracer, audit tree, levels, annotations, explanations) | Snapshot tests; under 3% overhead with the tracer off | done |
| 5 | jlens-schema: inference, overrides, JSON Schema, static typing, completion/diagnostics/hover | Table-driven language-service tests | done |
| 6 | Full CLI | CLI snapshot tests | done |
| 7 | WebAssembly package (browser ESM), TypeScript types, web editor example | Headless browser tests; working demo | done |
| 8 | Documentation consolidation, benchmarks, publishing | Doc examples verified; link check green; published to crates.io and npm | done (0.1.0) |
Releases
The changelog lists every change.
| Version | Highlights |
|---|---|
| 0.1.0 | The first release: the engine, audits, schemas and the language service, the CLI and the WebAssembly package |
| 0.1.1 | Examples, fixtures and the web editor use an online store order |
| 0.2.0 | Host functions in Rust and WebAssembly (ADR 0006), and their declarations in the language service; the web editor’s example selector |
| 0.3.0 | The language server jlens lsp and jlens.json (ADR 0007, editors guide); the CLI’s machine contract (ADR 0008): eval --json, --now and --seed, check over several files, jlens errors; cancellation and size caps in jlens-core |
| 0.4.0 | check warns about unknown functions, wrong argument counts and types, and undeclared variables, and reads jlens.json; jlens test runs files of test cases; prebuilt binaries, installable with cargo binstall |
| 0.5.0 | A cap on the size of a result (U1001); the size caps, clock and seed in the WebAssembly package, and the size caps in jlens eval and jlens test; differential testing against jsonata-js (cargo xtask differential); every binary built before a release publishes anything; a security policy |
| 0.6.0 | Regular expressions bounded by the timeout and cancellation (ADR 0009), on jlens-regress, a fork of regress 0.12.0; differential tests that reach regular expressions, pictures and decoders; npm provenance and build attestations for the binaries |
| 0.7.0 | Three jsonata-js quirks that expose JavaScript are no longer reproduced: a regular expression applied to a value that is not a string, irregular grouping in $formatNumber, $substringBefore and $substringAfter without chars |
| 0.8.0 | Coverage-guided fuzzing in a weekly Fuzz workflow, and the panics, stack overflows and slow cases it found fixed; jlens lsp survives a panicking request; evaluate reads its input lazily, one level at a time (ADR 0010), natively and in WebAssembly, and evaluate_source reads other representations |
| 0.9.0 | A number that overflows to infinity and a date beyond JavaScript’s range fail with D1001 where they arise; $formatNumber and $formatBase write every digit of a large number; loops over floating-point numbers end on NaN and infinity; a parser stack overflow found by fuzzing fixed; cargo xtask fuzz-replay replays fuzz inputs on any platform |
| 0.10.0 | A recursion through a higher-order function fails with D1011 instead of overflowing the stack; WebAssembly’s default maxDepth is 500; cargo xtask ab compares the speed of two revisions |
| 0.11.0 | Faster evaluation of common shapes: field lookups on a single value, comparisons with a string literal, order-by keys evaluated once, blocks without frames, the result converted to JSON in one pass, less set-up per evaluation |
| 0.11.1 | $fromMillis and $now fail with D3150 on a timezone that is not a number |
| 0.12.0 | The book and the web editor published at https://thkprado.github.io/jlens/ at each release tag; the editor evaluates in a Web Worker and shares expressions by address |
| 0.13.0 | Host functions call the functions they receive, in Rust (HostCall::function, Callable) and in WebAssembly (a JavaScript function), within the evaluation’s limits (ADR 0011) |
| 0.14.0 | The jlens command from npm (jlens-cli, ADR 0012), Homebrew, Scoop and the container image ghcr.io/thkprado/jlens, all from the attested release binaries |
| 0.15.0 | The VS Code extension thkprado.jlens on the Marketplace and Open VSX, with the release binary bundled (ADR 0013), and mimalloc as the allocator of the jlens command |
| 0.16.0 | Faster group-by (no longer quadratic in the size of a group), paths of names, literal group keys and names mapped over many items, closing the gaps with jsonata-core |
Priorities
Three principles order the work after 0.2:
- The core is a library for custom integrations. Applications embed
jlens-corein their own pipelines. It must stay small and decoupled, let the application set every limit, never panic, and change its API only on purpose. - The CLI is a tool for automation and AI agents. It never prompts, it gives the same output for the same input, and it has one documented machine-readable contract: what goes to standard output, what goes to standard error, and what each exit code means.
- Editors come after. The language server is done; the VS Code extension is 0.15.
For ad-hoc JSON queries, jq remains the usual tool. jlens is for JSONata that will run
somewhere else, such as a rules engine or a jsonata-js service. It checks those expressions,
tests them and explains their results before they ship.
0.3: a machine contract and core guardrails
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | ADR 0008, the CLI’s machine contract: a JSON envelope for results and errors, exit codes, the roles of stdout and stderr, never reading a terminal | ADR accepted; the CLI guide has a “For scripts and agents” section | done |
| 2 | Options becomes #[non_exhaustive], with builder methods. A fixed clock ($now, $millis) and a random seed ($random, $shuffle) | The same expression, input and options give byte-identical results; crate guide snippets updated | done |
| 3 | jlens eval --json: one compact envelope per evaluation, {"ok":true,"result":…}, {"ok":true,"undefined":true} or {"ok":false,"error":{code, message, position, line, column}}. With --ndjson, one envelope per line, carrying the line number | Snapshot tests for a result, undefined, a syntax error, an evaluation error and invalid input | done |
| 4 | jlens eval --now and --seed | CLI tests with $now() and $random() | done |
| 5 | eval fails with code 2 instead of waiting when standard input is a terminal and neither -i nor --no-input is given | CLI test | done |
| 6 | jlens check takes several files and glob patterns, adds file, line and column to its JSON, prints it compact, and has --deny-warnings | Snapshot tests; the CI loop in the CLI guide becomes one command | done |
| 7 | jlens errors [CODE] [--json], from the error catalog | Snapshot tests | done |
| 8 | Cancellation from another thread, and caps on the size of built strings and arrays (U1001) | Property tests; an expression that builds a huge value stops with U1001 | done |
| 9 | The public API is reviewed before it grows further: what jlens-core exposes, and Send/Sync of Expression and Document, asserted in tests. cargo-semver-checks runs in CI | CI job green | done |
0.4: catching mistakes before they run
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | check warns about unknown functions, with a suggestion ($summ → $sum), and about calls with the wrong number of arguments | Language-service and CLI tests; no warning on host functions declared in jlens.json | done |
| 2 | check warns about undeclared variables, when declarations are given, and about argument types that cannot match a signature, when the types are known | Table-driven tests without false positives on the conformance suite’s expressions | done |
| 3 | check --config jlens.json, and the jlens.json of the current directory by default: the CLI and the editors see the same schema, variables and functions | CLI tests sharing a fixture with the language server tests | done |
| 4 | jlens test: runs a file of cases (input, bindings, expected result or error code) and reports the failures, as text or as --json | Snapshot tests; guide page | done |
| 5 | Prebuilt binaries for Linux, macOS and Windows attached to each release, installable with cargo binstall | A release with the binaries | done |
0.5: safe releases, enforced limits and proven compatibility
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | The release workflow builds every binary before it publishes anything, and CI builds the release targets on each push to main | crates and npm need binaries; the CI job is green on main | done |
| 2 | The library’s guarantees are checked: a CI job with the declared minimum Rust version (1.88), cargo-deny for advisories and licenses, and a SECURITY.md for reporting vulnerabilities | CI jobs green; SECURITY.md linked from the README and CONTRIBUTING | done |
| 3 | A cap on the total size of a result, which counts a shared value each time it appears, so that a short expression that nests the same array cannot exhaust memory when the result is serialized (U1001) | Property tests; such an expression stops with U1001 | done |
| 4 | The WebAssembly package takes every option of jlens-core but cancellation (maxStringLength, maxArrayLength, the size cap of item 3, now, seed), and jlens eval and jlens test take the size caps | Tests in the JS suite and the CLI; the guides list the options | done |
| 5 | Differential testing against jsonata-js 2.2.2: generated expressions and inputs run in both engines, which must agree on the result or the error code | A cargo xtask command and a scheduled CI job; each divergence fixed, or documented in the compatibility page and the overrides | done |
0.6: bounded regular expressions, wider differential tests, signed packages
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | A regular expression cannot outlast the limits of an evaluation. Today the timeout and cancellation are checked between evaluation steps, never inside a match, so catastrophic backtracking ($contains('aaaaaaaaaaaaaaaaaaaaaaaaaa!', /^(a+)+$/)) runs for minutes, and freezes the page in WebAssembly, whatever the timeout. The design (a linear-time matcher where the pattern allows it, a step budget, or both) is decided first, in an ADR | Property tests; such a match stops with an error code within the timeout, in Rust and in WebAssembly; the compatibility page documents the difference | done |
| 2 | The differential tests reach the code that was hardest to port: regular expression literals ($match, $replace, $split, $contains, ~>), valid pictures for $formatNumber, $formatInteger and $fromMillis, and the functions the generator leaves out ($sift, $each, $match, $base64decode, $decodeUrl, $decodeUrlComponent, $encodeUrl) | Generator extended; each divergence fixed, or documented in the compatibility page with a reasoned exception | done |
| 3 | Released packages prove where they were built: the npm package is published with provenance, and the binary archives carry build attestations | The next release shows provenance on npm and attestations on GitHub; CONTRIBUTING says how to verify them | done |
0.8: proven robustness and fast single evaluations
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | Coverage-guided fuzzing backs the promise never to panic. The property tests run a fixed number of cases in CI, and the code keeps invariants with expect and unreachable! that only a panic would reveal broken: in the language server, a panic on a half-typed expression stops the server; in WebAssembly, it traps and leaves the instance unusable. cargo-fuzz targets cover the parser (normal and recover mode), compiling and evaluating under limits, jlens-regress, the pictures of $formatNumber, $formatInteger and $fromMillis, date parsing in $toMillis, and the language service. jlens lsp answers a request that panics with an error and keeps running | A scheduled Fuzz workflow, like Differential; every crash it found fixed, with a regression test; an LSP test for a panicking request | done |
| 2 | The cost of a single evaluation is measured and cut where the value model allows it. evaluate converts the whole input before it reads any of it: on the 24 KB document of the benchmarks, a path takes 153 µs natively and 402 µs in WebAssembly, against 0.9 µs and 2.5 µs with a Document, and 2.1 µs in jsonata-js. In WebAssembly the input goes through JSON.stringify, serde_json and the conversion to values | The performance page shows the time of each step; the steps that can go without changing the value model are gone; no benchmark case is slower | done |
| 3 | Lazy conversion of input documents: an evaluation converts only the parts of its input it reads. This changes the value model (ADR 0003); the design comes first, in ADR 0010 | ADR accepted; with evaluate, the path case is within 10× of evaluate_document, natively and in WebAssembly; property tests; no other benchmark case more than 3% slower | done |
0.9: numbers and dates at the edges, and tools for the next steps
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | CI lints the workflows: actionlint, with shellcheck for the scripts of run steps | A CI job; the workflows pass it | done |
| 2 | cargo xtask release-notes writes links that work on the GitHub release page. The changelog links pages relatively (](docs/src/...)), which break there; for 0.8.0 they were fixed by hand in the draft | Relative links become absolute at the tag (blob/vX.Y.Z/...); a unit test | done |
| 3 | A fuzz input can be replayed without libFuzzer. The targets cannot link on Windows, so a crash is reproduced today with a throwaway program that repeats the target’s calls | One command runs a saved input (such as last-input from a failed run) through the body of any target, on a 1 MB stack like the fuzzing thread, on any platform; the fuzzing section of the conformance page says how | done |
| 4 | Loops and recursion over floating-point numbers end on NaN and infinity. Fuzzing found two that did not (number_to_words, decimal_to_letters); the others are found by reading the code rather than waiting for the weekly run | Every loop or recursion whose end depends on an f64 reviewed; each one that could run forever or overflow the stack fixed, with a regression test | done |
| 5 | Decide where an infinite number stops. 1e308 * 10 is infinity in both engines, and jsonata-js raises D1001 only when an infinite number is the operand of another operator, so it reaches functions and comes out as a result. jlens may fail at the operator that overflows instead | Behaviour of both engines recorded; the decision made and implemented; the compatibility page, and the differential rules when jlens differs | done |
| 6 | $formatNumber formats large numbers exactly. $formatNumber(1e21, '#,##0.00') gives "1e,+21.00" in both engines: the exponent of JavaScript’s number-to-string conversion leaks into the picture | The digits of the number formatted through the picture; the compatibility page and the differential rules | done |
| 7 | One model for dates beyond JavaScript’s range. Today $fromMillis(1e17, '[Y0001]') computes a year (“0843”) while the day of the year and the week are NaN, because only part of the date code clips like V8; jsonata-js gives NaN everywhere | One model chosen (clip like JavaScript, or never clip) and applied to every date component; the compatibility page | done |
0.10: performance and robustness debts
Explorations: each one may end in a fix, or in the cause written down and the debt accepted. Both outcomes close the item.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | A/B benchmarks as a command. Criterion is bimodal on the maintainer’s machine, so changes are measured by building one program per variant and running them alternately, by hand | A command (an xtask or a script in benches/) that builds two revisions and compares them by alternating runs; the performance page says how | done |
| 2 | The cost lazy input left on evaluations that read their whole input: regex +4%, filter +2.8%, strings +2.7% when measured, above the 3% that 0.8 allowed | Measured again with the A/B command; the cost removed, or recorded on the performance page with its cause | done |
| 3 | WebAssembly’s default maxDepth back to 1000. Since lazy input, an audited recursion near 1,000 levels overflows V8’s stack while the function is optimized, so the default became 800; the cause of the larger frames was not found | The cause found and the default restored, or the cause (or what was ruled out) recorded in the WebAssembly guide | done |
| 4 | Two crashes of the fuzzing runs, both stack overflows without a message, never reproduced: one while parse loaded its corpus, one in pictures | The cached corpora and any input saved since (last-input) replayed with the replay command; a crash that reproduces fixed, with a regression test; otherwise what was tried recorded in the fuzzing section | done |
| 5 | Two cargo xtask ab runs at once both fail when they build the same revision: each adds and removes the same temporary worktree in target/ab | Concurrent runs either share the build (a lock, or a worktree per run) or the second stops with a clear message; the performance page says whether runs may overlap | done |
0.11: faster evaluation of common shapes
A comparison with jsonata-core 2.2.11, another Rust
port, on the cases of benches/ (evaluate_document against its evaluate, both converting the
result to serde_json::Value) found it 2 to 5 times faster on five cases: path (0.9 against
0.1 µs), filter + aggregate (164 against 93 µs), group-by (570 against 123 µs), sort + map
(1.79 ms against 655 µs) and the shipping rule (433 against 254 µs). Both engines are about
equal on the other cases, and jlens is faster on regex. Measuring
parts of these expressions traced the gaps to a few places in eval.rs, where work is repeated
or allocated that no result depends on. Each item removes one of them without changing any
result, error or evaluation order that jsonata-js shows. jsonata-core’s other technique, a
second evaluator for simple expressions, is left out: two evaluators of the same language can
disagree.
Every item is measured with cargo xtask ab against the commit before it, and checked with the
conformance suite and a cargo xtask differential run. When a tracer is attached, evaluation
takes the general path, so audits keep reporting every node.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | Sort keys are evaluated once per item. The order-by operator (^(...)) evaluates the keys of both items at every comparison, as jsonata-js does: about 3,000 evaluations to sort 200 items, where 200 suffice. items^(tax) takes 1.05 ms, against 208 µs in jsonata-core | Each item’s keys computed on first use and kept, so the keys are evaluated in the same order and the first error is the same; tests for T2007 and T2008; sort + map faster with ab; the compatibility page notes that a key using $random is drawn once per item | done |
| 2 | A block without a binding creates no frame. (...) allocates a frame each time it is evaluated, so items.(unitPrice) takes 85 µs where items.unitPrice takes 16 µs | Post-processing marks the blocks whose own expressions bind a variable (:=), and only those create a frame; tests for scoping and for closures defined in a block; ab | done: items.(unitPrice) 83 → 77 µs; the rest of the gap is the one-step path of #3. A block that calls a function keeps its frame when the expression names $eval, which binds in its caller’s frame |
| 3 | A field name evaluated on an object is a lookup. A bare name such as unitPrice is a path of one step, which builds and flattens sequences: about five allocations per evaluation. items.(unitPrice * quantity) takes 159 µs, against 39 µs in jsonata-core | A one-step path of a name, without stages, on an input that is not an array, returns the field when its value is not an array, and takes the general path otherwise; unit tests for the sequence and singleton rules it skips; filter + aggregate, group-by and the shipping rule faster with ab | done: filter + aggregate 161 → 56 µs, group-by 842 → 575 µs, shipping rule 414 → 152 µs, strings 198 → 104 µs (ab --mode document); a property test checks that evaluation agrees with an audit, which takes the general path |
| 4 | A string literal does not allocate each time it is evaluated. Today it builds a new Rc<str>, so items[type = 'electronic'] allocates one string per item. Exploration: a string shared by the evaluation, or a comparison with the literal that builds no value | The allocation gone and measured with ab, or the cost and the options recorded on the performance page | done: = and != compare with the literal’s text, untraced: filter + aggregate 56 → 44 µs, strings 105 → 92 µs, shipping rule 150 → 127 µs (ab --mode document). Other uses (arguments, &, object values) still build a string: sharing one per evaluation needs a slot per literal, since the AST is Send and values use Rc |
| 5 | The result is converted in one pass. Converting the whole document as a result takes 210 µs, against 152 µs in jsonata-core: its size is measured in a first walk, then it is converted in a second | The size counted while converting, stopping at Options::max_result_size as before; the property tests of the size cap pass unchanged; a large result faster with ab | done: returning the whole input ($) 271 → 236 µs (ab --mode document); the transform case, whose result is built, is unchanged, since its time goes to building it |
| 6 | The fixed cost of an evaluation is cut. 1 takes 0.35 µs, against 0.07 µs in jsonata-core: the clock is read twice for $now and the seed of $random, and the root frame and the binding of $ allocate | The clock read once, and only what an evaluation uses set up; the path case faster with ab | done: the clock is read on first use, the start time only with a timeout, and the input is kept in the root frame instead of a binding of $: 1 350 → 106 ns, path 927 → 676 ns (ab --mode document) |
| 7 | The performance page shows the new numbers, natively and in WebAssembly, with the comparison with jsonata-js | The tables measured again; the “Where the time goes” section updated | done: natively, evaluate_document is 2 to 4 times faster than in 0.8 on filter + aggregate, sort + map and the shipping rule; jlens-wasm evaluateDocument is now faster than jsonata-js on all ten cases |
0.12: jlens online
Today the book and the web editor exist only in the repository: the README links no site, CI
builds the book only to check it, and trying the editor takes a clone, a Rust toolchain and
cargo xtask wasm. This release publishes both on GitHub Pages, at each release tag, as
try.jsonata.org does for jsonata-js. The editor shows what sets
jlens apart (completion from an inferred schema, diagnostics, the explanation and audit tree of
every result), and everything runs in the browser: no expression or document leaves the
visitor’s machine.
The site is the book at the root and the editor under editor/, built from the files of the
tag, so the editor runs the engine that the book of that version describes. The first
deployment, and enabling Pages in the repository settings, wait for the maintainer.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | One command builds the site. cargo xtask site builds the book and the WebAssembly package and assembles them in target/site: the book at the root, the editor in editor/ with the package in editor/pkg/. The editor imports the package by a bare name (jlens-wasm) mapped in its import map, so the same files run from the repository (../../crates/jlens-wasm/pkg/jlens.js) and from the site (./pkg/jlens.js): the command rewrites only that entry | The command; cargo xtask serve can serve target/site (a flag), and wasm-test --example also loads the built editor; the editor README and CONTRIBUTING.md say how to preview the site; the book’s introduction and the WebAssembly guide link the editor | done: the book links the editor at its published address, which works from the first deployment (#2) |
| 2 | A Pages workflow publishes the site at each v* tag, and by hand on a tag (workflow_dispatch), with actions/upload-pages-artifact and actions/deploy-pages. book.toml sets site-url = "/jlens/", so the book’s 404 page works under the project path | The workflow passes actionlint; the maintainer has set Pages to “GitHub Actions”; a first deployment of the last tag, made with the maintainer’s confirmation, serves the book and the editor at https://thkprado.github.io/jlens/; homepage in Cargo.toml and in the npm package, and the README, point to it (crates.io and npm show it from the next release) | done: the first deployment waits for the 0.12.0 tag (#5), since 0.11.1 has neither the workflow nor cargo xtask site; the github-pages environment must allow v* tags |
| 3 | The editor stays responsive. Evaluation runs on the page’s thread with a 2 s timeout, so a slow expression freezes typing for up to 2 s at each keystroke. Evaluation moves to a Web Worker, and an evaluation still running when the expression changes is dropped (the worker is replaced) | Typing never waits for an evaluation; the timeout and D1011 are reported as now; wasm-test --example passes for the repository and the site | done |
| 4 | Expressions can be shared. A “Share” button puts the expression, the input and the bindings in the address: compressed with CompressionStream (deflate-raw), in base64url, after #, so that no server stores them. Opening such an address restores them; the named examples (#invoice) keep working. The header links the book and says that evaluation runs in the browser | A round-trip test of the encoding; a size limit (some tools cut addresses beyond about 8 KB: the button says so instead of giving a broken link); the editor README documents the format | done |
| 5 | Release 0.12.0, which deploys the site of its tag | The release checklist of CONTRIBUTING.md, with a step for the Pages run; the site serves 0.12.0 | done: step 7 of the checklist checks the Pages run |
0.13: host functions that call back
A host function cannot call a JSONata function it receives as an argument: the argument arrives
as None (undefined in WebAssembly), as ADR 0006 decided. Rule engines need it for helpers
written in the host language that apply a function given to them ($retry($fetch, 3),
$memoize(function($k) {...}), a lookup with a fallback function). This release adds a
callable handle without changing the functions already written against ADR 0006.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | The design, in ADR 0011. First, what jsonata-js does: in benches/compare, register a JavaScript function, pass it a lambda, a built-in function ($uppercase) and a partial application, and record what it receives and whether it can call each. Then the Rust API; a proposal to check: HostCall::function(i) -> Option<Callable<'_>> and Callable::call(&self, args: &[Option<JsonValue>]) -> Result<Option<JsonValue>>, keeping the JSON boundary. The handle borrows the call, so it cannot outlive it. The nested evaluation counts toward the same depth, timeout, cancellation and size limits; its tracer events nest under the host call’s node; its errors come back to the host function as Err with their codes, and pass through when the host function returns them | ADR 0011 accepted by the maintainer, with the jsonata-js findings; any difference planned for the compatibility page | done: ADR 0011; four differences planned for the compatibility page (#2, #3) |
| 2 | The Rust implementation, as ADR 0011 decides | Unit tests: lambdas, built-in and host functions, partial applications and closures passed back; errors and their codes; a recursion through a host function ends with D1011; a timeout inside the callback; a property case in robustness.rs; an audit snapshot with a nested call; a snippet in guide.rs and the host function section of the crate guide; cargo xtask ab shows no change on the untraced path | done: HostCall::function and Callable; the crate guide’s functions as arguments |
| 3 | The same in WebAssembly. A JavaScript host function receives a JavaScript function for a JSONata function argument; calling it after the host function has returned throws, with a jlens code (U) | The four updates of a Wasm API change (crates/jlens-wasm/AGENTS.md); tests in suite.mjs for Node and the browser; the WebAssembly guide | done: a JavaScript function with an arity property; functions as arguments |
| 4 | Release 0.13.0 | The release checklist; ADR 0006 points to ADR 0011 | done |
0.14: more ways to install the CLI
Today the jlens command comes from the release archives, cargo binstall or cargo install.
Tools like jq also come from the package managers people already
use. Each channel adds a step to every release, and one more place where a release can fail. So
they come in order of reach, each published by release.yml from the archives already built
and attested, never rebuilt, and each one stays only if it updates without manual work.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | The CLI from npm, designed. The idea noted earlier, a bin in jlens-wasm, needs either a second CLI in JavaScript, which could drift from the contract of ADR 0008, or jlens-cli compiled to WASI, which Node.js runs only with an experimental warning. The third way is the one of esbuild and Biome: a small jlens package whose optionalDependencies are one package per platform, each holding the release binary, picked by os and cpu | The decision recorded in ADR 0012, with the npm names checked to be free | done: ADR 0012; jlens is taken on npm, so the command comes from jlens-cli and five jlens-cli-<os>-<cpu> packages |
| 2 | The npm packages, published by release.yml with provenance, after the binaries | npm pack of every package in CI, and a job that installs the packed packages on Linux, macOS and Windows and runs jlens --version; the installation page; npx jlens-cli --version works after the release | done: cargo xtask npm-cli builds the six packages from the archives; CI packs them and runs the installed jlens on Linux, macOS and Windows; release.yml publishes them with provenance. npx jlens-cli is checked with the 0.14.0 release (#6) |
| 3 | Homebrew. A tap, thkprado/homebrew-jlens (created by the maintainer), with a formula for the macOS and Linux archives and their checksums from SHA256SUMS; release.yml updates it with a token limited to that repository | brew install thkprado/jlens/jlens installs the release; the formula’s test runs jlens --version; the installation page; the token’s scope documented in CONTRIBUTING.md | done: cargo xtask homebrew writes the formula from SHA256SUMS; the Homebrew workflow commits it to the tap when a release is published (a draft’s archives cannot be downloaded yet), and CI installs and tests the formula with the CI archives on Linux and macOS. brew install is checked with the 0.14.0 release (#6) |
| 4 | Windows: a Scoop bucket, thkprado/scoop-jlens, with a manifest that updates itself from the GitHub releases (checkver, autoupdate). winget is left out for now: each version is a pull request that Microsoft reviews | scoop install jlens works after adding the bucket; the installation page | done: cargo xtask scoop writes the manifest from SHA256SUMS, with a checkver and an autoupdate that Scoop’s checkver.ps1 turns into the same manifest; the Scoop workflow commits it to the bucket when a release is published, and CI installs the manifest with the CI archive on Windows. scoop install is checked with the 0.14.0 release (#6) |
| 5 | A container image, ghcr.io/thkprado/jlens: FROM scratch with the static Linux binaries (x86-64 and ARM64), built from the release archives and attested | docker run --rm -i ghcr.io/thkprado/jlens eval '...' works on both architectures; the installation page | done: crates/jlens-cli/Dockerfile unpacks the musl archive of the platform being built into a scratch image, so the multi-platform build runs no emulated step; release.yml pushes it with the run’s token, tagged X.Y.Z, X.Y and latest, and attests it; CI builds it from its own archives and runs it on x86-64 and ARM64 runners. The pull from ghcr.io is checked with the 0.14.0 release (#6) |
| 6 | Release 0.14.0 | The release checklist, with a check of each channel after the release | done, except npm: Homebrew, Scoop, the container image and the archives checked; npm refused jlens-cli-win32-x64 (“Package name triggered spam detection”), so jlens-cli was not published (#7) |
| 7 | The CLI on npm, after #6. The four other platform packages are on npm at 0.14.0; a support ticket asks npm to allow the name. Without an answer by 2026-10-20, the platform packages move to a scope (@jlens/cli-<os>-<cpu>), which changes ADR 0012. Before the next release tag, either the name is allowed, the packages are renamed, or the npm job of the CLI is left out of that release | npx jlens-cli --version works; the installation page and the release notes show the npm channel again | pending: left out of 0.15.0 and 0.16.0, as release.yml publishes the CLI to npm only when the repository variable PUBLISH_NPM_CLI is true |
0.15: jlens in VS Code, and a faster CLI allocator
jlens lsp has worked since 0.3, but VS Code starts language servers only from extensions, and
most JSONata users write in VS Code. Neovim, Helix and Emacs need a few lines of configuration;
VS Code needs an extension. Like the other channels, the extension ships the attested release
binary, never a rebuilt one. The milestone also settles the allocator question of 0.8: on
Windows, mimalloc made every benchmark 20 to 45% faster, and Linux has not been measured.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | The extension, designed: where the server comes from (one VSIX per platform with the release binary, as rust-analyzer does, a jlens found on the PATH, or a download at first start), a setting that points to another jlens, the language id and file extensions next to other JSONata extensions, the publisher on the Visual Studio Marketplace and Open VSX, and where its TypeScript lives in this repository | The decision recorded in ADR 0013, with the names checked to be free on both registries | done: ADR 0013; one VSIX per target (the musl binaries serve linux- and alpine-) plus one that uses the PATH; thkprado.jlens on both registries; the Marketplace credential is chosen in #5, as global Azure DevOps tokens end on 2026-12-01 |
| 2 | Syntax highlighting: a TextMate grammar for JSONata (comments, strings, numbers, names, $variables and function calls, operators, regular expressions) and a language configuration (comments, brackets, auto-closing pairs) | The grammar’s snapshot tests run in CI over the expressions of the suite and the book’s examples, and cover every token kind | done: editors/vscode/syntaxes/jsonata.tmLanguage.json, tested with vscode-textmate, the engine of VS Code. Snapshots of fixtures that use every scope of the grammar; over the 1,941 expressions of the suite and the book, no character is left without a scope, nothing stays open at the end, and the grammar finds the regular expressions jsonata-js’s parser finds (a / is division or a regular expression depending on what precedes it) |
| 3 | The extension starts jlens lsp with vscode-languageclient for .jsonata files: the setting for the server’s path, a command that restarts it, its log in an output channel, a clear message when no server is found, and a JSON Schema for jlens.json so that VS Code checks it as it is edited | An extension test (@vscode/test-electron, in CI on Linux, macOS and Windows) opens an expression and gets a diagnostic, a completion and a hover from the server; the editors guide | done: editors/vscode/src/extension.ts, over vscode-languageclient 10 (VS Code 1.91 or later). The server is looked for in jlens.server.path (user settings only), the bundled binary, then the PATH; the jlens output channel shows its version. The test opens fixtures/workspace, as the server’s and the CLI’s tests do, and checks the diagnostics, a completion, a hover, the restart command and the validation of jlens.json |
| 4 | Packaging: cargo xtask vscode builds one VSIX per release target from the archives, as cargo xtask npm-cli does, plus one without a binary for other platforms, which looks for jlens on the PATH | CI packs every VSIX and installs the one of its platform in the extension test | done: xtask/src/vscode.rs stages the extension and runs vsce package once per target with the binary of its archive in server/, and once without; the version is the workspace’s. CI packs them on Linux, where the binaries keep their executable bit, then installs the package of each OS in VS Code and runs the extension test with the bundled binary (JLENS_VSIX), which it also checks to be executable |
| 5 | Publishing: release.yml publishes the VSIX files to the Marketplace and to Open VSX after the binaries, skipping a version already published; the credentials, chosen as ADR 0013 says, and the steps to renew them are documented in CONTRIBUTING.md | The release checklist checks the extension on both registries | done: the vscode job of release.yml builds the VSIX files with cargo xtask vscode and publishes them with vsce and ovsx --skip-duplicate, from the GitHub environment vscode, which accepts only version tags. The Marketplace is reached as a Microsoft Entra ID application federated with that environment; Open VSX with a token for the first version, then through trusted publishing. Neither keeps a long-lived secret; CONTRIBUTING.md says how to set them up, and the checklist installs the extension from the Marketplace and lists its targets on Open VSX |
| 6 | The allocator of the CLI, measured on Linux: a cargo xtask ab run on a Linux runner, with and without mimalloc as the global allocator of jlens only; the library keeps leaving the choice to applications | If Linux gains about as much as Windows, jlens links mimalloc on every release target, cargo-deny accepts it, and the performance page shows both platforms; otherwise the measurement goes to the performance page and the item closes | done: on the same machine, mimalloc makes the Linux release binary (musl) 28 to 91% faster, Windows 14 to 56% and a glibc build up to 25%, so jlens uses it on every release target; cargo xtask ab gained --b-allocator mimalloc and --target, and the performance page shows the three platforms |
| 7 | Release 0.15.0. Before the tag, the npm channel of the CLI (0.14 #7) is settled or left out of the release | The release checklist, with the extension installed from both registries | done: the npm channel of the CLI is left out (0.14 #7) |
0.16: the last gaps with jsonata-core, and string literals
A new comparison with jsonata-core 2.2.11, after 0.15.0 and on the same cases as in 0.11, finds
jlens ahead on seven of the ten. Of the three left, sort + map is close (638 against 577 µs), and
two have a cause: group-by (375 against 119 µs), which copied each group at every item, and
the path case (0.7 against 0.1 µs), where the fixed cost of an evaluation dominates. 0.11 #6 cut
that cost from 350 to 106 ns for 1, but a path still takes about seven times as long.
| # | Deliverable | Definition of done | Status |
|---|---|---|---|
| 1 | Group-by joins each group once. Evaluator::group appends each item to its group with functions::append, which copies the group, so a group of k items costs O(k²): items{"n": $count($)} takes 153 µs, against 38 µs in jsonata-core | Each group collects its items and builds its value once, with the same rules (undefined skipped, a single item kept as it is, arrays flattened one level); unit tests for those rules; group-by faster with ab | done: group-by 64% faster with ab, and 108 against 119 µs in jsonata-core; a group of 200 items 8 times faster. Tuple streams are reduced the same way |
| 2 | The fixed cost of a path. The path case takes 0.7 µs, against 0.1 µs in jsonata-core. Exploration: profile an evaluation of a short path on a Document and list what it sets up or allocates that no result depends on | The cost cut and measured with ab (the path case faster, no other case slower), or what takes the time, and why it stays, recorded on the performance page | done: path 67% faster with ab (681 to 227 ns), sort + map 33% ($i.code), shipping rule 7%, no case slower; 0.23 against 0.14 µs in jsonata-core. A path of names, or one starting with a variable, looks its fields up while no array is met. The rest (fixed cost, result conversion) and the per-item cost of items.code are on the performance page |
| 3 | String literals in arguments, & and object values do not allocate each time they are evaluated. 0.11 #4 removed the allocation only for = and !=; elsewhere a literal still builds a new string. Sharing one per evaluation needs a slot per literal, since the AST is Send and values use Rc. Exploration: measure its share of the time first, with cases added to benches/ | Below about 5% of those cases, the share and the options recorded on the performance page; above it, the allocation gone and measured with ab, no other case slower | done: about 3% where a literal is an argument, a & operand or an object value per item, recorded on the performance page; above it, literal keys of a grouping, now looked up by their text: group-by −12%, the new object constructor case −5%, nothing slower (ab) |
| 4 | A name over many items. items.code takes about 110 ns per item, against about 65 ns in jsonata-core, and $count(items) 0.94 against 0.41 µs. Looking the field up without evaluating the step saves only 12%, so the cost is elsewhere. Exploration: count the allocations per item, then separate the cost of the call from the cost of the step | The cost cut and measured with ab (the cases that map a name faster, no other case slower), or what takes the time, and why it stays, recorded on the performance page | done: allocations were not the per-item cost; copies were. A field holding an array is returned as it is, a step passes its items to the next without copying them into a new sequence, and results with no array to flatten are not copied twice. New case names over items ($sum(items.quantity)) 28% faster with ab --mode document, shipping rule 8%, group-by 4%, nothing slower; $count(items.code) 9.8 to 7.0 µs (4.9 in jsonata-core), $count(items) 0.94 to 0.67 µs. The rest is on the performance page |
| 5 | Release 0.16.0. Before the tag, the npm channel of the CLI (0.14 #7) is settled or left out of the release | The release checklist; the performance page shows the new numbers | done: the native, WebAssembly and allocator tables measured again, with the two new cases; the npm channel of the CLI is left out (0.14 #7) |
Later
Not commitments, in order of priority:
- Asynchronous functions, as in jsonata-js, whose functions may return promises. This needs
an evaluator that can suspend, and an ADR first. Until then, a host function that returns a
promise fails with
U1002.
Conformance testing
jlens vendors the language-neutral test suite of the reference implementation
(jsonata-js 2.2.2, MIT) in tests/jsonata-suite/.
That covers 102 groups and about 1,690 cases. The harness lives in
crates/jlens-core/tests/conformance.rs and mirrors test/run-test-suite.js:
- Each case is one test, named
<group>/<file>, or<group>/<file>#<index>for files that hold an array of cases. - The input comes from
data. If there is nodata, the input is the nameddataset;"dataset": nullmeans undefined input. timelimitanddepthmap toOptions::timeoutandOptions::max_depth.- Each case states exactly one expectation:
result, compared with deep equality where numbers compare as JavaScript numbers, so1equals1.0;undefinedResult;code, optionally withtoken;error, an object whose fields must all match.
The baseline ratchet
tests/conformance/baseline.txt lists the cases that must pass, and cargo test fails only if
one of them regresses. Every other case is reported as ignored. To work on a group, run it
with:
cargo test -p jlens-core --test conformance -- --include-ignored array-constructor/
To measure progress and lock in new passes:
cargo xtask conformance # writes tests/conformance/REPORT.md
cargo xtask conformance --update-baseline # also rewrites baseline.txt
Overrides
tests/conformance/overrides.json records every intentional deviation. Each entry needs a
reason and may skip the case, or replace its expectation with alternateResult or
alternateCode. The current overrides are:
| Case | Reason |
|---|---|
function-encodeUrl/case002, function-encodeUrlComponent/case002 | The data contains a lone UTF-16 surrogate (U+D800), which Rust’s UTF-8 strings cannot represent. |
Property tests
The suite checks that jlens gives the right answers. Property tests (proptest) check that no
input makes it crash, whatever the input:
| Test | What it generates | What must hold |
|---|---|---|
jlens-core/tests/robustness.rs | Valid expressions from a small grammar, near-valid token soups, and arbitrary strings | Parsing (strict and recovering), evaluation, evaluation against a Document, and audited evaluation never panic; diagnostic spans stay inside the source |
jlens-schema/tests/robustness.rs | Token soups and arbitrary strings | Completion, diagnostics, hover and signature help never panic at any byte offset; ranges stay inside the text |
They run 2,000 and 1,000 cases with cargo test. Set PROPTEST_CASES for longer runs:
PROPTEST_CASES=100000 cargo test -p jlens-core --test robustness.
The property tests found two bugs:
- In recovering parsing, an out-of-range number (
1e310) looped forever. - A transform such as
|items|{'p': $}|built an object containing itself, which then overflowed the stack when converted to JSON. It now fails withU1001.
Both have regression tests.
Fuzzing
Property tests draw their inputs at random. Coverage-guided fuzzing (cargo-fuzz, with
libFuzzer) keeps the inputs that reach new code and mutates them, so it finds the paths that
random inputs rarely reach. The targets live in fuzz/, a crate outside the workspace:
| Target | Input | What must hold |
|---|---|---|
parse | An expression | Strict parsing never panics |
parse_recover | An expression | Recovering parsing never panics; diagnostic spans stay inside the source |
evaluate | An expression, optionally followed by a NUL byte and a JSON input document | Compiling, then evaluating with and without input, against a Document and audited, never panics |
regex | A byte of flags, then a pattern and a text separated by a NUL byte | Compiling the pattern with jlens-regress and finding every match never panic; groups lie on character boundaries of the text |
pictures | A picture, optionally followed by a NUL byte and a second string | $formatNumber, $formatInteger, $parseInteger, $fromMillis and $toMillis never panic on the picture, the text they parse or the time zone |
language_service | Two bytes of offset, then a text | Diagnostics, completion, hover and signature help never panic, at any offset; their ranges lie inside the text |
Evaluations run with a timeout, a depth limit, size caps, and a fixed clock and seed. Each target runs on a thread with a 1 MB stack, the size of the main thread’s stack on Windows, so that recursion that does not grow the stack overflows on Linux too. The build has debug assertions, so an arithmetic overflow is a crash.
The Fuzz workflow runs every target for ten minutes each week, or for as long as asked when
started by hand. Each target starts from seeds that cargo xtask fuzz-seed writes to
fuzz/corpus/: the expressions and input documents of the JSONata suite, and a few regular
expressions and pictures. It also starts from the corpus its last run kept. A crash fails the
run, and the input that caused it is uploaded with the run, along with the corpus. A stack
overflow stops the target before libFuzzer can write the input; the targets write each input to
the file that JLENS_FUZZ_LAST_INPUT names before running it, and the workflow uploads that file
as last-input.
cargo-fuzz needs nightly Rust and Linux or macOS. To fuzz, on Linux:
cargo install cargo-fuzz
cargo xtask fuzz-seed
cargo +nightly fuzz run evaluate --sanitizer none --debug-assertions
The bodies of the targets live in fuzz/src/targets.rs, apart from libFuzzer. To reproduce a
crash on any platform, with stable Rust, replay the saved input, such as the last-input or
crash-* file of a failed run, or a whole directory of them, such as a corpus:
cargo xtask fuzz-replay evaluate fuzz-evaluate/last-input
cargo xtask fuzz-replay parse fuzz/corpus/parse
The replay runs each input through the target’s body on the same 1 MB thread, in an optimized build with debug assertions, as the workflow does. It prints the path of each input before running it, so the last path printed names the input of a stack overflow, which stops the program; a panic is reported and the next input runs.
Each crash becomes an ordinary test in the crate where the bug was, which runs with
cargo test on every platform.
Two early crashes left no input: stack overflows in pictures (run 38159469720) and in parse
(run 38158412916), both before the targets wrote last-input. In October 2026 every input the
failed runs kept was replayed: their corpora (45,148 parse and 17,703 pictures inputs),
their last-input and crash-* files, and the seeds of every target. None crashes. The
cached corpora cannot be downloaded, but each run executes its whole corpus before fuzzing, and
the run of 0.9.0 (38225748988) did so for all six targets without a crash. Both crashes most
likely had causes fixed since, which later runs found with an input: numbers in words recursing
on the NaN day that pictures began to produce in the commit of its crash, and a lambda body
whose tail calls were marked without growing the stack. They are not tracked further.
Differential testing
The suite covers the cases its authors thought of. Differential testing covers others: it generates expressions and runs them in jlens and in jsonata-js 2.2.2, which must agree on the result or, when the expression fails, on the error code.
(cd benches/compare && npm install)
cargo xtask differential # 1,000 cases, from a seed it prints
cargo xtask differential --cases 10000 --seed 42
The expressions come from a grammar like that of the property tests, without the functions that
read the clock or draw random numbers. The grammar also calls the functions that were hardest to
port with meaningful arguments: regular expression literals for $match, $replace, $split,
$contains and ~>, valid pictures for $formatNumber, $formatInteger and $fromMillis,
round trips through $parseInteger, $toMillis and the decoders. Each case runs against one of a few input documents, or
none. The command writes the cases to target/differential/cases.json.
benches/compare/differential.mjs runs them in jsonata-js. The command then compares each
outcome with that of jlens. Results are compared as JSON, with numbers compared as values and
the keys of an object in order. Both engines run with a timeout and a depth limit, and a case
that reaches either limit is skipped. The divergences are printed and written to
target/differential/divergences.json. The command fails when there are any. A seed
reproduces a run.
Ten kinds of disagreement are known differences of jsonata-js, listed in the compatibility notes. They are not divergences; the command counts them apart:
- jsonata-js crashed: it threw a JavaScript exception, which has no error code to compare.
- jsonata-js hung: the case ran for ten times the timeout, or ran out of memory, in a
synchronous loop that the jsonata-js timeout cannot interrupt (
$match('abc', /b/) ?? 1). The runner evaluates the cases in a worker thread, stops it, and goes on with the next case in a new one. - jsonata-js split a character: a string of the jsonata-js result holds a lone surrogate,
half of a character outside the Basic Multilingual Plane, which a jlens string cannot hold. A
JavaScript regular expression without the
uflag matches UTF-16 code units, so$match('a𝄞', /[^a]/)matches the first half of𝄞; jlens matches the whole character. - Regex left of
??or?:: the outcomes differ, and the expression has a regular expression literal on the left of??or?:. The jsonata-js parser copies that side through JSON, which turns the regex into{}, so the expression hangs (counted above) or gives another result. - Concurrent errors: both engines failed with different codes, and the expression has an array constructor with several items or an object constructor. jsonata-js evaluates those parts concurrently, so when two of them fail it reports whichever fails first in its asynchronous schedule. jlens reports the first in source order.
- jsonata-js changed its input: the jsonata-js runner saw the input document change during the evaluation (a group-by on an empty array adds an item to it), and the outcomes differ.
- Wildcard over a sequence: the expression has a wildcard, and the jsonata-js result is
jlens’s with
trueitems added. An array that jsonata-js builds from a path carries an own propertysequence: true, which its wildcard enumerates with the items. Without an input, the flag can change a result without appearing in it: a path whose first step is an array constructor evaluates it on the sequence[undefined], so[*].priceisundefinedin jsonata-js, which went on with[undefined, true], and[]in jlens. A case without input whose expression has such a path counts here whatever the outcomes. - Dropped quirk: the expression may meet a jsonata-js quirk that jlens does not reproduce:
a regular expression literal applied with
~>to anything but a string literal,$formatNumberwith a literal picture whose integer part has irregular grouping ('#,##,##0'), or$substringBefore/$substringAfterwhosecharsis not a string literal. - Overflow to infinity: jlens failed with
D1001, which it raises only where a number overflows to infinity (1 / 0). jsonata-js passes the infinity on, to its result or to an operator that fails later. - Exact digits of a large number:
$formatNumberfrom 1e21, where jsonata-js writes JavaScript’s exponent notation through the picture (1e,+21.00), and$formatBasefrom 2^53 in a radix that is not a power of two, where jsonata-js writes zeros for the digits below the number’s precision.
The Differential workflow runs 20,000 cases from a new seed every week, and on demand with a
chosen number of cases and seed. When it fails, the cases and outcomes are attached to the run.
Performance
jlens is a tree-walking interpreter, like jsonata-js. This chapter covers three things: how performance is measured, what the numbers are, and how to use the API to get the most from it.
Benchmarks
The workload is in benches/ at the repository root. It is shared by the native benchmarks and
by the comparison with jsonata-js:
benches/cases.jsonlists twelve expressions: path navigation, a name mapped over the items, filters with aggregation, grouping, sorting, string and regex functions, number formatting, an object constructor, a transform, a recursive function, and the shipping rule from the guides.benches/orders-200.jsonis a 24 KB store order with 200 items.
cargo bench -p jlens-core # criterion, native
cargo xtask ab main . # two revisions against each other, native
cargo xtask wasm && cd benches/compare && npm install && node compare.mjs # against jsonata-js
Comparing two revisions
To measure a change, compare the revision before it with the revision that makes it. Criterion
alone can mislead here: on some machines its results are bimodal, so two runs of one build can
differ by more than the change. cargo xtask ab is built for this:
cargo xtask ab main . # main against the working tree
cargo xtask ab v0.9.0 HEAD --case regex --case strings --rounds 20
cargo xtask ab main . --mode document # evaluate_document instead of evaluate
- It builds one program per revision, against that revision’s
jlens-core, with the release profile. A revision is anything git names (a commit, a tag, a branch), or.for the working tree with its uncommitted changes. A commit is built once, in a temporary git worktree, and its program is kept intarget/ab/bin. - Two runs may overlap: each builds in its own directory,
target/ab/run-<process id>, and they share the programs already built. Their measurements disturb each other, though: two runs of nearly the same code at once differed by 8% on the machine below. Compare revisions one run at a time. - It runs the two programs in alternation, ten rounds by default (
--rounds): A then B, then B then A, so that a drift of the machine reaches both. In each run, a case takes the fastest of five samples of about 10 ms each (--sample-ms). - It prints, for each case, the median of the rounds for each revision and how much slower B is, then the same for the fastest round. Judge by the median. A whole process is sometimes faster than usual, by up to 10% on the machine below, and one such run sets the fastest time.
Comparing a revision with itself shows the noise. On the machine below, with ten rounds, the
medians of most cases agree within 1%, and a few cases (group-by) within 3%.
The numbers below were measured on an AMD Ryzen 7 3700X under Windows 11, with Rust 1.99 and Node.js 24, for jlens 0.16.0. They are meant for comparing alternatives on one machine. They are not guarantees, and your hardware will give different absolute values.
Native (criterion)
Each evaluate call reads its serde_json::Value input as the expression reaches it, one
object or array at a time (ADR 0010). evaluate_document
evaluates against a Document converted beforehand.
| Case | evaluate | evaluate_document |
|---|---|---|
path (customer.name) | 1.2 µs | 0.24 µs |
names over items ($sum(items.quantity)) | 114 µs | 7.5 µs |
| filter + aggregate | 153 µs | 48 µs |
| group-by | 193 µs | 88 µs |
| sort + map | 549 µs | 476 µs |
| strings | 202 µs | 102 µs |
| regex | 294 µs | 178 µs |
| number formatting | 213 µs | 182 µs |
| object constructor | 449 µs | 328 µs |
| transform | 989 µs | 852 µs |
recursion ($f(15), Fibonacci) | 1.03 ms | 1.02 ms |
| shipping rule | 224 µs | 114 µs |
| Other measurements | Time |
|---|---|
Compile customer.name | 1.1 µs |
| Compile the shipping rule (30 lines) | 42 µs |
Document::parse of the 24 KB document | 168 µs |
Document::new from a serde_json::Value | 108 µs |
serde_json::from_str of the same text, for reference | 230 µs |
Shipping rule, audit level decisions | 1.92 ms (7.8× without a tracer) |
Shipping rule, audit level full | 9.17 ms (37× without a tracer) |
An audit costs about what it did in 0.8 (1.95 ms and 7.56 ms; level full 21% more), but the
ratios have doubled: without a tracer, evaluation takes the shortcuts described under
Where the time goes, and with one it takes the general path, so that
the audit reports every node.
The path case reads two objects of the input, the document and its customer: evaluate
takes 1.2 µs, where converting the whole input first took 115 µs (91 µs to convert, 17 µs to
drop, 0.9 µs to evaluate). An expression that reads every item pays for converting them, as
before.
When measured for 0.8, on this machine, such an expression cost more with lazy input than it did when evaluate
converted the whole input first: measured with cargo xtask ab against the revision before
lazy input (d0e9789), filter + aggregate is 7% slower, strings 10% and regex 4%. The
conversion is not the cause:
- The whole cost comes from step 2 of ADR 0010, and it does not depend on reading lazily. Converting the whole input eagerly at the current revision costs the same.
- Converting the document alone (
Document::newand its drop) takes 94 µs at both revisions, andevaluate_documentis as fast as before. - The cause is Windows’ default allocator. With
mimallocas the global allocator in both programs, the gap falls to 1–3%, which is the cost of the lazy values themselves, and every one of these cases runs in about half the time.
An application that calls evaluate on whole documents can set a faster global allocator;
the library leaves that choice to it. The jlens command sets one.
The allocator of the command
Evaluation allocates for most of its work, so the allocator sets much of its speed. The jlens
command uses mimalloc as its global allocator on every
release target. cargo xtask ab . . --b-allocator mimalloc measures the allocator alone: B is
the same revision with mimalloc. --target x86_64-unknown-linux-musl builds both programs
for musl, as the Linux release binaries are.
Measured for 0.16 (2f069f7) with evaluate, the median of ten rounds, on the machine above;
Linux is Ubuntu 26.04 under WSL 2, on the same machine:
| Case | Windows | + mimalloc | Linux, glibc | + mimalloc | Linux, musl | + mimalloc |
|---|---|---|---|---|---|---|
| path | 1.21 µs | 698 ns | 573 ns | 611 ns | 12.5 µs | 12.1 µs |
| names over items | 156 µs | 69 µs | 79 µs | 60 µs | 285 µs | 83 µs |
| filter + aggregate | 194 µs | 106 µs | 114 µs | 94 µs | 350 µs | 120 µs |
| group-by | 213 µs | 137 µs | 151 µs | 125 µs | 500 µs | 151 µs |
| sort + map | 553 µs | 351 µs | 338 µs | 303 µs | 845 µs | 346 µs |
| strings | 204 µs | 123 µs | 132 µs | 109 µs | 419 µs | 140 µs |
| regex | 294 µs | 168 µs | 176 µs | 151 µs | 527 µs | 179 µs |
| number formatting | 210 µs | 145 µs | 149 µs | 131 µs | 382 µs | 157 µs |
| object constructor | 443 µs | 254 µs | 362 µs | 234 µs | 890 µs | 265 µs |
| transform | 1.01 ms | 595 µs | 681 µs | 548 µs | 1.88 ms | 606 µs |
| recursion | 1.01 ms | 867 µs | 738 µs | 775 µs | 1.03 ms | 726 µs |
| shipping rule | 220 µs | 174 µs | 178 µs | 153 µs | 393 µs | 180 µs |
- musl’s own allocator is the slowest of the three: with it, the release binary for Linux takes
up to 3.6 times as long as a glibc build (
names over items), and took ten times as long ongroup-byin 0.15, when that case copied each group at every item. On every case butpath,mimallocmakes it 30 to 71% faster, and within 40% of glibc withmimalloc. - On Windows,
mimallocsaves 14 to 56%. - glibc’s allocator is the fastest of the three.
mimallocstill saves 10 to 36% on ten cases;pathtakes 38 ns more, andrecursion5% more. pathcosts about 12 µs more on musl with either allocator: a fixed cost of each evaluation there, which the allocator does not explain. A command evaluates once, so its start-up hides it.- macOS was not measured. Its binaries use
mimalloctoo, so that every platform runs the same allocator.
WebAssembly against jsonata-js (Node.js)
Both run in the same Node.js process, on the same input. The WebAssembly module is the release
build optimized by wasm-opt -Os. The comparison script first checks
that both produce the same result for each case. The jsonata-js times include the await
that its asynchronous evaluate requires.
| Case | jsonata-js 2.2.2 | jlens-wasm evaluate | jlens-wasm evaluateDocument |
|---|---|---|---|
| path | 2.4 µs | 7.3 µs | 1.7 µs |
| names over items | 70 µs | 355 µs | 16 µs |
| filter + aggregate | 682 µs | 389 µs | 55 µs |
| group-by | 1.11 ms | 453 µs | 109 µs |
| sort + map | 5.33 ms | 864 µs | 528 µs |
| strings | 599 µs | 422 µs | 100 µs |
| regex | 641 µs | 518 µs | 206 µs |
| number formatting | 563 µs | 591 µs | 262 µs |
| object constructor | 1.22 ms | 805 µs | 484 µs |
| transform | 1.37 ms | 1.60 ms | 1.24 ms |
| recursion | 7.90 ms | 1.04 ms | 1.03 ms |
| shipping rule | 1.66 ms | 469 µs | 148 µs |
jsonata-js works on the JavaScript objects it is given, so it never pays for converting the
input. jlens-wasm evaluate reads the input object as the expression reaches it: an object or
array of up to 16 values through one call to a JavaScript helper, a larger one whole, through
JSON.stringify and a parse into jlens’s representation. The path case reads two small
objects. An expression that reads every item pays what converting the whole input does:
JSON.stringify of the input takes 49 µs, and parsing that text 263 µs.
evaluate is faster than jsonata-js on eight of the twelve cases, by up to 7.6× on recursion.
It is slower on four:
path, which does almost no work, so the overhead of calling into WebAssembly dominates;names over items, by 5.1×: it reads every item and does little with each, so converting the input is nearly all of its time;number formatting, by 5%, because it reads every item;transform, whose deep copy of the document is the job V8’s own JSON round trip does best.
Against a Document converted once, jlens-wasm is faster on all twelve cases, by up to 12× on
filter + aggregate, 11× on the shipping rule and 10× on group-by and on sort + map. On
transform its lead is 10%.
Using the API efficiently
- Compile once.
Expression::compile(new Expressionin JavaScript) parses and post-processes. A compiled expression is immutable: keep it, share it between threads (it isSend + Sync), and evaluate it as often as needed. - Reuse documents.
evaluateconverts the parts of the input that the expression reads. For an expression that reads all of a 24 KB document, that costs about 110 µs natively, and about 300 µs in WebAssembly. To evaluate several expressions against one document, such as a set of rules, or one expression repeatedly, convert the document once. - Prefer JSON text when you have it.
Document::parse(evaluateJsonin JavaScript) parses straight into jlens’s representation. That is faster than parsing into aserde_json::Valueand converting it. - Audit at the level you need. Without a tracer, auditing costs nothing measurable; see
ADR 0004. Level
decisionsrecords only what explains a result.fullrecords every node, with its input, and is for debugging.
Reusing documents
#![allow(unused)]
fn main() {
use jlens_core::{Bindings, Document, Expression, Options};
let doc = Document::parse(r#"{"items": [{"price": 2}, {"price": 3}]}"#)?;
let rules: Vec<Expression> = ["$sum(items.price) > 4", "$count(items) < 10"]
.iter()
.map(|source| Expression::compile(source))
.collect::<Result<_, _>>()?;
for rule in &rules {
let outcome = rule.evaluate_document(&doc, &Bindings::new(), &Options::default())?;
}
Ok::<(), Box<dyn std::error::Error>>(())
}
const doc = new Document(input); // or Document.fromJson(text)
for (const rule of rules) rule.evaluateDocument(doc);
doc.free();
Evaluations never modify a document, so one document serves any number of evaluations. A
Document is not Send. Convert it on the thread that uses it.
Where the time goes
- Allocation. Values are reference-counted (
Rc). Objects areIndexMaps that keep insertion order, hashed withfoldhash, which is fast and randomly seeded. Their keys are shared strings: converting a document allocates each distinct key once, not once per object. Building results, sequences and tuple streams allocates, and that is most of the cost of evaluation. - Sequences. Following jsonata-js, paths build sequences step by step and flatten them. Predicates evaluate per item, in a fresh frame.
- Shortcuts for common shapes. Without a tracer, evaluation skips work that no result
depends on. A path of names evaluated on a single object, such as
priceinitems.(price * quantity)orcustomer.name, or starting with a variable, such as$i.code, looks its fields up instead of building a path’s sequences, unless a field before the last holds an array. A name mapped over the items of a path (codeinitems.code) is looked up in each item without evaluating the step for it. - String literals.
=and!=with a string literal compare with the literal’s text instead of building a string, and so does a grouping with a literal key, which finds its group by the text: initems{type: {'total': ..., 'n': ...}},'total'and'n'are built once per group instead of once per item (group-by took 12% less time). The literal’s other uses (arguments,&, object values) still build one each time. In 0.16 that was measured at about 3% of expressions that use a literal once per item, such asitems.(code & '-' & type)oritems.$pad(code, 8, '0'): too little for a slot per literal in each evaluation, which the AST needs because it isSendwhile values useRc. - Tracing. With a tracer, evaluation takes the general path, so audits report every node; a property test checks that both paths give the same result.
- Blocks and sorting. A block (
(...)) creates a frame for its variables only when it binds one. The order-by operator (^(...)) evaluates each item’s keys once, not at each comparison. - Fixed cost. An evaluation reads the clock only when it needs it (
$now,$millis, the seed of$random, a timeout), so1takes about 0.12 µs, against 0.06 µs in jsonata-core.customer.nametakes 0.23 µs, against 0.14 µs: that fixed cost, the result converted to aserde_json::Value(a new string;'a'alone takes 0.21 µs), and two lookups. Before 0.16 a path of two names built a sequence for each step and took 0.69 µs;$.customer.nametook 1 µs and now takes 0.24 µs. - Names over many items. A path step that reads an array passes it on as it is:
itemsreturns the input’s array, and the step after it maps over that array’s items instead of a copy of them. A step whose results hold no array to flatten makes them its sequence without copying them again. Before 0.16,$count(items.code)took 9.8 µs and now takes 7.0 µs, against 4.9 µs in jsonata-core;$count(items)went from 0.94 to 0.67 µs (0.39 µs). Looking the field up is not what remains:$count(items.nope)takes 4.9 µs, against 5.7 µs. A field found costs about 10 ns more per item, to keep the value, anditems.codetakes 20 µs, against 14 µs, most of it converting the 200 strings of its result. - Converting the input.
evaluateconverts the parts of the input that the expression reads, as it reads them (ADR 0010). An expression that reads every item pays for converting them, and that is now most of its time: filter + aggregate takes 153 µs withevaluateand 48 µs against aDocument. - Deep copies. A transform (
|...|) copies its input with$clonebefore changing it, as jsonata-js does. That makes transforms of large documents comparatively expensive.
Decision records
Architecture decision records (ADRs) capture significant design choices: the context, the decision, and its consequences. They are numbered and never rewritten. A later ADR supersedes an earlier one instead.
| # | Decision | Status |
|---|---|---|
| 0001 | Implement JSONata ourselves, porting jsonata-js 2.2.2 | Accepted |
| 0002 | Use regress for ECMAScript-compatible regular expressions | Accepted |
| 0003 | An explicit value model with Undefined and sequence flags | Accepted |
| 0004 | Audit evaluations through a tracer interface in the evaluator | Accepted |
| 0005 | Carry audit labels in JSONata doc-comments | Accepted |
| 0006 | Host functions with a JSON boundary | Accepted; extended by 0011 |
| 0007 | A language server on the language service, configured by jlens.json | Accepted |
| 0008 | A machine contract for the CLI: --json envelopes, exit codes, stdout for data | Accepted |
| 0009 | Regular expressions stop within the evaluation’s limits, through a fork of regress | Accepted |
| 0010 | Input documents converted lazily, one level at a time | Accepted |
| 0011 | Host functions call the functions they receive, through a handle that borrows the call | Accepted |
| 0012 | The CLI on npm: a launcher package and one package per platform | Accepted |
| 0013 | The VS Code extension: one package per platform with the release binary | Accepted |
0001 — Implement JSONata ourselves, porting jsonata-js 2.2.2
Status: Accepted
Context
The project began as a rule engine built on a home-grown JSON DSL over JSONPath. It had
all/any/not groups, vars, aggregate formulas such as sumProduct and avgField, and an
audit tree. Each new requirement added more operators to that DSL, and it was drifting towards an
under-specified programming language. JSONata already offers everything that DSL needed, and
more:
- path navigation and filters;
- aggregation;
- variables;
- conditionals and result construction.
JSONata also has a specification and a language-neutral conformance suite.
We considered two options:
- Build on an existing Rust JSONata crate. That is less work up front. However, the existing crates cover the language only partially. They don’t expose an AST with stable spans or a recovering parser, and we would depend on a third party for the parts we need to extend most: auditing and tooling.
- Write our own implementation as a port of the reference implementation, jsonata-js 2.2.2.
Decision
We write our own implementation and port the reference implementation stage by stage:
tokenizer, Pratt parser, processAST, evaluator, function library, date/time formatting and
signatures.
The Java port (jsonata-java) and its annotated walkthrough serve as a secondary reference, especially for the pitfalls they document.
Correctness is measured against the official test suite (see Conformance testing).
Consequences
- We control the AST, which lets us add precise spans, a recovering parser, trivia and comment annotations, and evaluator instrumentation for audits.
- The cost is substantial: roughly 7,700 lines of JavaScript to port, including the date/time picture-string engine.
- Conformance is objective and tracked per case, so behaviour differences are visible and must
be justified in
tests/conformance/overrides.json. - We track upstream JSONata releases by re-vendoring the suite (
cargo xtask update-suite).
0002 — Use regress for ECMAScript-compatible regular expressions
Status: Accepted
Context
JSONata regex literals (/ab+c/i) and the functions $match, $replace, $split and
$contains follow JavaScript regular-expression semantics. The suite relies on features
such as backreferences, lookahead and lookbehind, and on JavaScript’s capture-group behaviour.
The regex crate deliberately omits backreferences and lookaround, because it guarantees
linear-time matching. fancy-regex adds them, but its syntax and semantics are Perl/PCRE-like
rather than ECMAScript.
Decision
Use regress. It implements ECMAScript regular-expression
syntax and semantics in pure Rust, and it compiles to wasm32-unknown-unknown.
Consequences
- We get regex behaviour that matches jsonata-js closely, including in the browser build.
regressis a backtracking engine, so a pathological pattern can take exponential time. The evaluation timeout guards against this (seeOptions::timeout), and the guides will warn about untrusted patterns. (It did not: a match ran as one evaluation step. ADR 0009 makes the timeout reach inside a match.)- Positions reported by
$matchmust be converted from UTF-8 byte offsets to the UTF-16 code unit indices that JavaScript reports. That conversion is covered by dedicated tests.
0003 — An explicit value model with Undefined and sequence flags
Status: Accepted
Context
JSONata semantics depend on distinctions that plain JSON values cannot express:
- undefined vs null. A missing path yields undefined, which disappears from arrays and
objects. JSON
nullis a real value. - Sequences vs arrays. Path results are sequences. An empty sequence is undefined, and a
singleton sequence collapses to its only item. Arrays that come from the input, or that an
explicit
[...]constructor builds, are never collapsed. The[]modifier (keepArray) and the wrapper that makes an input array count as a single context value add more cases. - Callables. Lambdas, native functions and regexes are first-class values.
The Java port encodes these as a null for undefined, a NULL_VALUE sentinel for JSON null, and
an ArrayList subclass that carries flags. Its documentation names this the most error-prone
part of the port.
Decision
The evaluator works on its own Value type:
#![allow(unused)]
fn main() {
enum Value {
Undefined, Null, Bool(bool), Number(f64), String(Rc<str>),
Array(Rc<Array>), // Array carries: sequence | keep_singleton | outer_wrapper | cons | tuple_stream
Object(Rc<IndexMap<..>>), // insertion order is observable in JSONata
Lambda(..), Native(..), Regex(..), Range(..),
}
}
- Numbers are
f64, as in JavaScript. Number-to-text conversion usesryu-jsso it matches JavaScript exactly. Undefinednever crosses the public API: results areOption<serde_json::Value>.- Values are reference-counted (
Rc) for the lifetime of a single evaluation. The compiled AST is immutable andSend + Sync, so a compiledExpressioncan be shared between threads, and each thread evaluates with its own values.
Consequences
- Each JSONata rule about undefined and sequences maps to a variant or a flag, and each is covered by a dedicated test.
- Input is converted once from
serde_json::Valueat the start of an evaluation. If profiling shows that this conversion dominates, a borrowed view of the input can be introduced later.
0004 — Audit evaluations through a tracer interface in the evaluator
Status: Accepted
Context
The original rule-engine design produced an audit tree for every evaluation. It recorded what each condition checked, the actual and expected values, and whether the condition passed. jlens keeps the language pure JSONata, with no separate rules layer, but it must still explain why an expression produced its result. JSONata itself only returns the final value.
Options considered:
- Re-evaluate sub-expressions after the fact. This is simple, but it doubles the cost, and it cannot see the state inside the evaluation: per-item filter outcomes, short-circuits and bound variables.
- Rewrite the expression to emit diagnostics. This is brittle and changes semantics.
- Instrument the evaluator with a tracer that receives events as evaluation proceeds.
Decision
The evaluator is generic over a Tracer trait. The trait receives these events:
enterandexitper node;predicateper filtered item;branch,bind,callandshort_circuit.
Two implementations exist:
NoopTraceris the default. All of its methods are empty and inlined, so tracing costs close to nothing when it is off. The budget is under 3%, checked by benchmarks.AuditCollectorbuilds a serializableAuditTree. It offers capture levels (Off,Decisions,Full) and capture limits: value size, items per sequence, depth and node count.
An Explanation is derived from the tree: which operands made an and fail, which items a
filter rejected, and so on. It renders as text for the CLI or as JSON for user interfaces.
Optional tracing integration and JSONL event export cover operational logging.
Measurement
The untraced hot path stays the same as before: when no tracer is attached, Evaluator::eval
checks for one and calls the original code, while the traced variant is a separate, cold
function. Interleaved A/B runs of a throughput example (since replaced by the criterion
benchmarks in crates/jlens-core/benches) against the evaluator before
tracing measured between −5.7% and +2.7% across five workloads, which is within the machine’s
noise and inside the 3% budget.
Consequences
- Auditing reuses the real evaluation, so what the audit shows is exactly what happened.
- The evaluator code has to emit events consistently. This adds work that is comparable in size to the core evaluator, and the roadmap plans for it as its own phase.
- Audit trees for large inputs are bounded by the capture limits, so audits stay usable in production.
0005 — Carry audit labels in JSONata doc-comments
Status: Accepted
Context
Business users need audit output in their own terms. They want to read “Authorized customer:
failed”, not “binary in at 12..48 returned false”. The original DSL had label and message
fields on every condition. JSONata has no syntax for metadata, and adding new syntax would make
expressions unportable to other JSONata implementations.
Decision
Annotations are written as JSONata block comments that start with /**. Each annotation
attaches to the sub-expression that immediately follows it:
/** @label "Authorized customer" @message "Login must be on the approved list" */
customer.login in $approved
- The lexer keeps comments as trivia, with spans. The parser attaches a doc-comment to the next AST node it produces.
- Recognised tags are
@label,@messageand@id. Tag values are JSON strings. Unknown tags produce a diagnostic warning, not an error. - Plain
/* ... */comments remain comments and carry no meaning.
Consequences
- Annotated expressions are still valid JSONata, and every other implementation ignores the annotations.
- Labels appear in the
AuditTree, in explanations, and in editor hovers. - Annotation placement depends on the parse. The annotation documentation will specify precisely
which node a comment attaches to, including for operators and for
and/orchains.
0006 — Host functions with a JSON boundary
Status: Accepted; extended by ADR 0011, which lets host functions call the functions they receive
Context
jsonata-js lets applications pass their own functions in the bindings of an evaluation, or
register them on an expression with registerFunction. Rule engines use this to reach data and
services the document does not hold: rate tables, currency conversion, lookups. jlens had no
equivalent, because Bindings held only JSON values.
A host function has to fit four constraints:
ExpressionisSend + Sync, andBindingsis too. Applications share both between threads.- The run-time
Value(ADR 0003) is internal. It is built onRc, so it is notSend. It also carries the sequence flags of jsonata-js, which a host should not have to know about. - Evaluations never mutate their input, and a
Documentis shared between evaluations. - Without a tracer, the evaluator must not get slower (ADR 0004).
Options considered for the values that cross the boundary:
- Expose
Value, with accessors. This is the fastest option, with no conversion, and callbacks are possible. But it makes the internal model, its flags and itsRcs a public API. - A new public value type. It needs a conversion anyway, and adds a third value model next
to
serde_json::Valueand the internal one. serde_json::Value, withNonefor undefined. This is the type of documents, results and variable bindings already, and every Rust JSON tool works with it.
Decision
- The boundary is JSON. A
HostFunctionwraps anArc<dyn Fn(&[Option<JsonValue>], &HostCall) -> Result<Option<JsonValue>> + Send + Sync>. Arguments are converted with the same rules as results (JSON.stringify): a function argument arrives asNone. The context value is converted only when the function asks for it (HostCall::input), since it can be the whole document. - Optional signatures reuse the parser and validator of the built-in functions. A validated
call behaves like a call to a built-in: same type errors, same context substitution. A
function without a signature declares its arity, which plays the part of a JavaScript
function’s
lengthfor higher-order functions and partial application. - Bindings hold values and functions. A name holds one or the other: binding one replaces the other. Each evaluation binds the host functions in its root frame next to the variables. So they take precedence over built-in functions of the same name, as in jsonata-js.
- Function values gain a
Hostvariant. Partial application of built-in functions is generalized to host functions:Partialholds the target function. - Errors are
jlens_core::Errors with a code chosen by the host. As for any error, the call site fills in the position and token when the error has none.
Consequences
BindingsandHostFunctionareSend + Sync. In WebAssembly, JavaScript functions are notSend, so the bindings refer to them indirectly, through a registry local to the evaluation.- In WebAssembly,
thisisundefined, where jsonata-js passes the context value. Passing it would convert the context value to JavaScript at every call, which is the whole document for a call at the top of the expression. A signature with-passes it as an argument instead. An exception thrown by a JavaScript function is kept, and the evaluation rethrows it as is. A promise is refused (U1002), since evaluation is synchronous. - Each call copies its arguments and its result. For the functions that rule engines call, which take a few scalars, the cost is small. It is large only for a function that receives a large array or object.
- A host function cannot call a JSONata function it receives as an argument. A later decision can
add a callable handle, or a public value type, without changing the functions written against
this API. ADR 0011 added that handle (
HostCall::function) in 0.13.0. - The evaluator’s hot path only gains match arms. Tracing needs no new event: a host call is a
function call node, with its
enterandexitevents. - Host functions run to completion. The evaluation’s timeout is checked again once they return.
0007 — A language server on the language service, configured by jlens.json
Status: Accepted
Context
The language service (jlens-schema) completes, checks and documents expressions against a
schema. The browser reaches it through the WebAssembly package. Other editors (Neovim, Helix,
Emacs, VS Code) speak the Language Server Protocol. They need a server, and a way to know
which schema, variables and host functions apply to which files.
Choices to make:
- The protocol library.
tower-lspis asynchronous and bringstokio.lsp-serverandlsp-types, the libraries of rust-analyzer, are synchronous: a loop over messages on channels, with few dependencies. The language service is synchronous and fast (it parses and types one expression), so nothing would run concurrently. - The binary. A separate
jlens-lspexecutable, or a command of the existing CLI. - The configuration. Settings per file, written in each expression (an annotation such as
@schema), or one file per workspace that maps paths to settings.
Decision
lsp-serverandlsp-types. The server is a loop over messages. It keeps the open documents and one language service per configuration entry, built on first use.- A library crate,
jlens-lsp, run byjlens lsp. Installing the CLI installs the server; embedders can run the library on another connection, as the tests do in memory. jlens.jsonat the root of each workspace folder. It maps glob patterns, relative to its folder, to settings: a schema file, variables with their schemas, and host function declarations. The first matching pattern applies. Expressions stay pure JSONata, and one file configures a whole folder of rules. The same object can come asinitializationOptions.- Reloading. The server asks the client to watch
**/*.json. Any change to a JSON file reloads the configuration and forgets the built services, so changed schemas are read again, and the open files are checked again. Saving a JSON file in the editor does the same, for clients that do not watch files. - Positions. UTF-8 when the client offers it, UTF-16 otherwise. Positions past the end of a line are clamped to it, and a position inside a character means its start.
Consequences
- Every editor with an LSP client gets the features of the web editor, with no editor-specific code. Only VS Code needs an extension to start the server, which jlens does not publish yet.
- Requests are answered one at a time, in order. That is enough while the service answers in well under a millisecond. Expensive features would need cancellation, or a worker thread.
- Configuration lives outside the expressions, so the same expression can be checked against different schemas in different folders. The cost is a file to maintain next to the rules.
- Reloading on any JSON change is coarse, but cheap: services are rebuilt lazily.
0008 — A machine contract for the CLI
Status: Accepted
Context
Scripts, CI jobs and coding agents run jlens and read what it prints. Today they parse output
written for people:
jlens evalprints the result as JSON, but an undefined result prints nothing, which looks the same as a failure that printed nothing. Errors go to standard error as one line of text, with the position inside the message.- With
--ndjson, failures are text on standard error, with a line number in the message; the results on standard output carry no line number at all, so a consumer cannot pair a result with its input line once a line has failed. evalreads standard input when no-iis given. Started from a terminal by mistake, it waits forever, which an agent cannot tell from a slow evaluation.- Results of
$now(),$millis(),$random()and$shuffle()change from run to run, so the same command cannot be replayed to check a result.
Choices to make:
- How output becomes machine-readable. Detect whether standard output is a terminal and
switch format (as
lsandgitdo), or add an explicit flag. - The shape of a result. The bare value, as today, or a value wrapped in an object that also says whether the evaluation succeeded.
- Where errors go in machine mode: standard error, as today, or standard output next to the results.
Decision
--json: one envelope per evaluation
jlens eval --json prints one compact JSON object, the envelope, per evaluation, on one
line of standard output:
| Outcome | Envelope |
|---|---|
| A result | {"ok":true,"result":…} |
| An undefined result | {"ok":true,"undefined":true} |
| An error | {"ok":false,"error":{"code":…,"message":…,"position":…,"line":…,"column":…}} |
undefinedis a separate field becausenullis a valid result.codeis the JSONata error code (S0202,T2001,D1012,U1001…), the one stable thing to match on.messageis for people and may change.positionis the JSONata position of the error, a UTF-16 offset into the expression, as in jsonata-js; it usually points just past the offending token.lineandcolumnare 1-based and point at the start of the offending text, with the column counted in characters, as the human report prints it. The three fields are absent when the error has no location.- With
--ndjson, every envelope also carries"line", the number of its input line, so results and errors can be paired with their input. Blank lines are skipped and produce no envelope. - With
--filter, a matching document is theresult. Documents that do not match produce no envelope; errors still do. - An input document that is not valid JSON is reported in an envelope too, with code
U2001. --jsonimplies--compact. It cannot be combined with--raw-output,--explainor--audit, which have their own output formats.- Fields are only ever added to the envelope, never renamed or removed. Consumers must ignore fields they do not know.
Exit codes
| Code | Meaning |
|---|---|
0 | Every evaluation succeeded |
1 | The expression has errors: a syntax error, an evaluation error, or (with --ndjson) at least one line that failed |
2 | Usage or I/O problem: bad arguments, an unreadable file, a single input document that is not valid JSON, a terminal on standard input |
The codes are the same with and without --json; only the output format changes.
Standard output is data, standard error is for people
- Standard output carries only data: results, envelopes, diagnostics with
--json, catalogs. - Standard error carries text for people: rendered diagnostics, warnings, usage errors.
- In
--jsonmode the envelope is complete: an expression error is in the envelope on standard output, and nothing needs to be read from standard error. - Problems with exit code 2 are reported on standard error only, and standard output stays
empty. They happen before any evaluation, and some of them (bad arguments) are detected
before
jlensknows which format was asked for. A single input document that is not valid JSON is the exception: in--jsonmode it is also reported as an envelope.
Never wait on a terminal
eval without -i or --no-input fails with exit code 2 when standard input is a terminal,
instead of waiting for a document that will never come.
The default stays human
Without --json, output is unchanged: results printed like jq, diagnostics rendered with the
source line and a caret. The format never depends on whether standard output is a terminal, so
a command behaves the same when an agent runs it and when a person copies it into a shell.
Related commands
jlens check --jsonprints one compact JSON array of diagnostics, each withfile,lineandcolumnnext to the code, message and byte span.--deny-warningsmakes warnings fail with exit code 1.jlens functions --jsonandjlens errors --jsonprint their catalogs on standard output.jlens eval --nowand--seedfix the clock and the random generator, so that the same command, expression and input print the same bytes.
Consequences
- Machine mode is one flag that a script or an agent passes on purpose. Nothing switches format behind its back, and the human output can keep improving without breaking anyone.
- A consumer reads one line per evaluation, decides on
ok, and matches errors bycode. It never parses messages or standard error. - Envelopes cost a few bytes per result. Pipelines that want bare values, such as
jlens eval --ndjson | jq, keep using the default output. U2001is a new code, for input rather than expressions. It joins the error catalog, and so the generated error reference.- The contract is public: changing it takes a new ADR, and the envelope only grows.
0009 — Regular expressions that stop within the evaluation’s limits
Status: Accepted
Context
ADR 0002 chose regress, a backtracking engine, and counted on the
evaluation timeout to stop pathological patterns. It does not. The evaluator checks the timeout
and the cancellation token between evaluation steps (Evaluator::guardrails in eval.rs), and a
match is a single step. Measured on 2026-10-11, in a debug build with
jlens eval --no-input --timeout 100, $contains('aa…a!', /^(a+)+$/) with n a’s
ran for 1.2 s at n = 20, 9.9 s at n = 23 and 80 s at n = 26. In WebAssembly the page
freezes for that long. Untrusted expressions were the use case the 0.5 limits were built for,
and a regex literal gets through them.
What regress offered when this was decided (0.10.5, the version in use, and 0.12.0, the
latest):
- No limit and no hook. Neither the public API nor the backtracker
(
classicalbacktrack.rs) has a step budget, a deadline, or a callback. - No linear-time backend. The optional
backend-pikevmis not a Pike VM in the automata sense: it explores states depth-first on a stack, cloning each state, and never merges duplicates, so it is exponential on the same patterns. It also panics on an instruction (Loop1CharBody) that the optimizer emits, and it is#[doc(hidden)].
Choices to make:
- How a match stops. Use a linear-time engine where the pattern allows one, give each match a step budget, or have the engine poll the evaluation’s own limits.
- What the error is. The timeout’s and cancellation’s codes (
D1012,U1003), or a new limit (U1001). - How the engine change ships, since
regresscannot do it today.
Decision
- The backtracker polls the evaluation’s limits. jlens matches through a fork of
regressthat takes an optional interrupt check: a callback the backtracker calls every N steps, which returns whether to stop. When it says to stop, the match returns an “interrupted” result instead of a match or no match.- A step is a unit of work that bounds everything between two checks: backtracks, and the
instructions run between them, nested lookarounds included. The exact unit and N are
settled by measurement during implementation. Targets: on the catastrophic cases, under
1 ms between two checks in a release build; on
benches/, under 2 % slower. - As implemented in
jlens-regress: a step is a failure that sends the backtracker back to its stack, or an iteration of a loop, and N is 1,024. Measured withcargo run --release -p jlens-regress --example interrupt_coston 2026-10-11: on five catastrophic patterns, a median of 9 to 13 µs between two checks (maximum about 0.2 ms, scheduling noise included); on ordinary matches, within the noise of the run (±4 %, a few nanoseconds per call). Measured again after the rebase onto 0.12.0: the same times between checks, and the same noise on ordinary matches except one, a backreference that backtracks on every word ((\w+)\s+\1on prose), 10 % slower thanfind_fromagainst 0 % on 0.10.5. The code paths are the same; the difference comes from how the compiler inlines the backtracker once the check is in. It applies only to evaluations with a timeout or a token. - jlens passes a check only when the evaluation has a timeout or a cancellation token. Without either, the match runs the unchanged code path (the check is a type parameter, so the “no check” version compiles to the current code). As with the tracer (ADR 0004), a feature that is not used costs nothing.
- Every match that user input reaches goes through it: the regex function values (
execandnextinregex.rs, which$match,$replace,$splitand$containsall use) and the regex that$toMillisbuilds from a picture (datetime.rs). The fixed internal patterns indatetime.rsneed no check.
- A step is a unit of work that bounds everything between two checks: backtracks, and the
instructions run between them, nested lookarounds included. The exact unit and N are
settled by measurement during implementation. Targets: on the catastrophic cases, under
1 ms between two checks in a release build; on
- The error is the limit that stopped it. A match that the timeout stops fails with
D1012, with the evaluation’s timeout as its value. One that a cancellation stops fails withU1003. These are the codes the same limits raise between steps, so callers handle nothing new and there is no new option. - The fork is a workspace crate,
jlens-regress, published on crates.io beforejlens-core.- It is
regressplus the interrupt API, and nothing else. The diff stays small so that it is easy to review and to rebase. It started from 0.10.5 and was rebased onto 0.12.0 on 2026-10-11 for the fixes upstream made in between: JavaScript semantics (the position restored when backtracking out of a lazy loop,icase folding, nested and negated classes,\xas an identity escape, duplicate named groups), and a nesting limit in the parser (256 levels), which turns a pattern that overflowed the stack into a syntax error. - It keeps the upstream licences (MIT or Apache-2.0) and says in its README that it is a fork, of which version, and why.
- jlens builds it with
regress’sprohibit-unsafefeature, so that nounsafepath runs. The workspace lintunsafe_code = "forbid"does not apply to it, because the upstream code keeps itsunsafeblocks behindcfg!checks. - The same API is proposed upstream as an issue and a pull request at
ridiculousfish/regress. The maintainer opens them. When aregressrelease offers an equivalent, jlens goes back toregressand deprecatesjlens-regress.
- It is
No default timeout is added, in Rust, the CLI or WebAssembly. Long legitimate evaluations would start failing, and the three would behave differently. The guides keep telling applications that run untrusted expressions to set a timeout. With one set, a regex can no longer outlast it.
Alternatives considered
- A linear-time engine (
regexorregex-automata) for the patterns it can run, keepingregressfor the rest. It cannot run backreferences or lookaround, so those patterns would still need the check. Its semantics differ from JavaScript’s in ways a translation would have to cover:.and line terminators,icase folding,\band\w, and captures that JavaScript resets on each iteration of a quantifier. It would also add Unicode tables to the WebAssembly package. It is worth revisiting for speed, but it does not remove the need for the check. - A separate step budget with
U1001, as a new limit. It would be deterministic, but it adds an option, it could reject legitimate matches on large inputs that the timeout allows, and jsonata-js has nothing like it. The timeout is already the limit for untrusted expressions. Tests stay deterministic with a short timeout and a pattern that would take minutes. - Static checks on the pattern (nested quantifiers, star height) with a cap on the input
length. Such checks are neither sound nor complete:
(a|a)*, the polynomiala*a*a*band backreferences slip through. They would also reject patterns that jsonata-js accepts. - Running the match elsewhere and abandoning it at the deadline. Native threads cannot be killed, and the WebAssembly build is single-threaded.
- Waiting for upstream instead of forking. That leaves the hole open on a timeline jlens does
not control. The fork is temporary, and it lands upstream if
regressaccepts it. - Copying
regressintojlens-core. That avoids a new crate, but it adds about 10,000 lines of engine and 31,000 lines of Unicode tables to the core crate. It would also mean rewriting everyunsafeblock to satisfy theforbidlint, which makes the copy hard to sync with upstream.
Consequences
- A catastrophic pattern stops within the timeout, give or take the time between two checks, in
Rust, in the CLI (
--timeout) and in WebAssembly. A cancellation token also stops it, from another thread. In WebAssembly only the timeout can, because the page’s single thread is busy matching. - This differs from jsonata-js, which has no timeout of its own and hangs on such a pattern. The compatibility page documents the difference.
- The release publishes one more crate, first: the publish order in
release.ymland the release checklist in CONTRIBUTING change.cargo denychecks its licence like any dependency. - Upgrading
regressnow means rebasing the fork. The fork’s tests run every backtracking match of upstream’s suite through the interrupt API as well, and the conformance baseline andcargo xtask differentialguard against changes in behaviour. - After 0.12.0, upstream added
Regex::find_from_budgeted(commit50bead7, unreleased as of 2026-10-11): a count of backtracks that ends the search with “budget exhausted”. It is not an equivalent: a count cannot follow a deadline or a cancellation, and a search restarted with a new budget starts over. The upstream proposal should build on it, for example with a callback when the budget runs out. - This ADR replaces ADR 0002’s statement that the evaluation timeout guards against pathological patterns. The rest of ADR 0002 stands.
- Property tests generate patterns with nested quantifiers on inputs that cannot match, and
check that each evaluation ends within its timeout with
D1012.
0010 — Input documents converted lazily, one level at a time
Status: Accepted. Amends ADR 0003, whose consequences left room for a borrowed view of the input.
Context
ADR 0003 converts the input from serde_json::Value into jlens’s own
values before an evaluation starts. The conversion visits the whole document, however little of
it the expression reads. On the 24 KB document of the benchmarks (performance page, 0.8.0), the
path customer.name takes:
evaluate | evaluate_document | |
|---|---|---|
| Native | 115 µs: 91 µs to convert, 17 µs to drop, 0.9 µs to evaluate | 0.9 µs |
| WebAssembly | 308 µs: 41 µs for JSON.stringify, 256 µs to parse and free | 2.5 µs |
jsonata-js takes 2.1 µs, because it reads the JavaScript object it was given. Roadmap 0.8,
item 3, asks for evaluate within 10× of evaluate_document on that case: 9 µs natively and
25 µs in WebAssembly, with no other benchmark case more than 3% slower.
Two facts bound the design:
- Natively, the input is borrowed.
evaluatetakesOption<&serde_json::Value>. Owning it means cloning it, which costs about as much as converting it. A lazy value must be able to point into the caller’s document. - In WebAssembly,
JSON.stringifyalone takes 41 µs, more than the whole budget. The input must be read from the JavaScript object itself, not from its text.
The value model’s rules stay: Undefined never crosses the API, evaluations never mutate their
input, a compiled Expression is Send + Sync, and a Document is converted once for many
evaluations.
Decision
- Objects and arrays expand one level at a time.
Valuekeeps its variants. AnArrayor an object’s map gains an optional pending source: the part of the input it stands for, not yet read. The first access expands it: its scalars are converted, and each of its arrays and objects becomes a new value with its own pending source. The level is then kept, so it is read at most once, and later accesses cost a branch.- The items of an array and the map of an object become private and are reached through
accessors that expand first (
items(),map(),map_mut()). The compiler then points at every place that read them directly, so none is missed. - A lookup expands the whole level of its object, not just one key. That costs the width of the object once, and keeps one value per field, so a field read twice is the same value.
- Keys go through the shared table of
value::Keys, kept for the whole evaluation. - A value whose source was never expanded, returned as a result, is converted to JSON
straight from its source (
customerreturns a clone of thatserde_jsonsubtree) without building jlens values. - The transform operator modifies only the copy that
$clonemakes, and$cloneserializes what it copies, which expands it. A$clonethat does not copy (the documented exception) modifies the expanded level, never the caller’s document.
- The items of an array and the map of an object become private and are reached through
accessors that expand first (
- Values carry the lifetime of the input:
Value<'a>. A pending source is an enum:Json(&'a serde_json::Value), which costs no allocation, orSource(Box<dyn Source<'a> + 'a>)for other inputs (item 4). The lifetime goes through the evaluator, environments and functions. Each evaluation picks one'a:evaluateand the other borrowed entry points use the lifetime of their input.evaluate_documentevaluates with'a = 'static: aDocumentis fully expanded when it is built, as today, so it never holds a pending source.Value<'a>is invariant (it holdsRefCells), so a value of one evaluation never mixes with another’s. Nothing changes for threads: values were neverSend, and anExpressionholds no values.- The public
audit::ValueRef<'r>keeps its single lifetime: it holds the value behind a private trait object, so tracers written today still compile.
- Bindings convert the same way. A variable given as
serde_json::Valuebecomes a lazy value over it. The results of host functions and the copies$clonemakes are owned, not borrowed; they stay eager. - Other inputs implement a public trait,
jlens_core::input::Source. One call expands one level into an opaque sink: null, booleans, numbers and strings are written as they are, arrays and objects as new sources, object fields after their key. The trait mentions no internal type, so the value model stays private. The WebAssembly package implements it overJsValue; an embedder can implement it over another JSON representation. The exact names are settled with the implementation, andcargo-semver-checksreviews them. - The WebAssembly package reads the JavaScript object.
- One call to a JavaScript helper per level returns its keys, its scalars and its nested objects, so the cost does not grow with the number of crossings between Wasm and JavaScript.
- The helper follows
JSON.stringify:toJSONis called, as for aDate; boxed primitives are unwrapped;undefined, functions and symbols are left out of objects and becomenullin arrays;NaNand the infinities becomenull. - The cases where
JSON.stringifythrows (a cycle, aBigInt, atoJSONthat throws) throw the sameTypeError, when the part of the input that holds them is read. Document.fromJsonand documents built from JavaScript objects stay eager, for repeated evaluations.
- Reading the input never returns an error to the code that reads it. Only a
Sourcecan fail (a JavaScript input), and making every accessor fallible would reach code that cannot fail today, such as equality and sorting. A failed expansion leaves the level empty and records the error in the evaluation; the evaluator fails with it at the next check of its limits (guardrails), before calling a host function, and before returning a result. Its code is a newU1004, whose message carries the source’s message; the WebAssembly package throws the originalTypeErrorinstead.
Implementation notes
- Step 2 (lazy levels with the
Jsonsource) measured how the pending source is stored, because every array and object of an evaluation pays for the test, lazy or not:- An array keeps its items in a plain field. A lazy array keeps its source and its read items
in a separate boxed part, so the other arrays test one plain field. A
OnceCellaround the items madeevaluate_documentup to 10% slower on group-by, since every access read through the cell. - An object keeps its
RefCellmap, and a lazy one fills it in place on first access; its source is a plain field and a flag. The accessors are namedborrow()andborrow_mut(), like theRefCellthey replace. - Scalars are converted directly when a level is read.
- An array keeps its items in a plain field. A lazy array keeps its source and its read items
in a separate boxed part, so the other arrays test one plain field. A
- On the benchmark document, step 2 measured:
evaluatepath 1.9 µs (115 µs before);evaluate_documentwithin ±3% of step 1;evaluatecases that read every item 0–4% slower than step 1, interleaving conversion with evaluation. Same-build runs on the maintainer’s machine sometimes differ by 25–55% on a fewevaluate_documentcases, so a case that moves must be measured again before it is trusted. - Step 3 (the
Sourcetrait,U1004and the WebAssembly source) settled the names:jlens_core::input::Source<'a>has one method,read(&self, sink: &mut Sink<'_, 'a>) -> Result<(), String>. A source stands for an array, an object, or the input itself, which writes the input as the only value of its level (nothing for undefined), so the input needs no type of its own.Expression::evaluate_sourcetakes it, with bindings that live as long as'a, since values of one evaluation share one lifetime.Sinkwrites scalars (null,bool,number,string), nested sources (array,object), and the key of the next field (key). Two methods serve speed:reserve, a hint of the number of values, andjson_members, which writes a level from JSON text, converted at once.- A failed read is recorded with the key table, in the state the lazy values of an evaluation
share. The evaluator tests a flag at each step, after converting the arguments of a host
function, and when the evaluation ends, whatever its outcome, so that an error caused by the
empty level never hides
U1004.
- In WebAssembly, writing a level value by value costs more per value than
JSON.stringifyand a parse in Rust: reading the whole benchmark document so took 560 µs, against 320 µs for the conversion it replaces. The JavaScript helper therefore returns a level of more than 16 values as itsJSON.stringifytext, which the source writes withjson_members; a text thatserde_jsoncannot read (a lone surrogate, more than 128 levels) is read again, value by value. A smaller level returns its tags and numbers in aFloat64Array, its strings and keys in one string, and its nested arrays and objects as nodes that keep their parent’s node, so that a cycle is detected asJSON.stringifydetects it. - On the benchmark document, step 3 measured, against step 2: natively, every case within ±2%;
in WebAssembly,
evaluatepath 7.6 µs (313 µs before), and the other cases within ±3.5% of the conversion throughJSON.stringify, in one Node.js process.
Alternatives not taken
- A borrowed variant,
Value::Json(&'a serde_json::Value). Every place that matches on arrays or objects (about 90) would handle two representations, and sequences need owned arrays anyway. Lazy levels inside the existing variants keep one representation. - Owning the input (
Rc<serde_json::Value>, with pointers into it).evaluateborrows its input, so owning it means a clone, which costs about as much as the conversion. Pointing into anRcfrom a value without a lifetime needsunsafeor a self-referential type. - Lookups that read one key without expanding the level. Cheaper on wide objects, but a
field read twice would be converted twice, into two different values. It can come later, for
the
Jsonsource, if a benchmark asks for it. - Reading the JSON text lazily in WebAssembly.
JSON.stringifyalone is over the budget. - A
jsfeature ofjlens-core, depending onjs-sys. It ties the core library to one host; a trait serves the Wasm package and any other. - Fallible accessors. See decision 6.
Consequences
- An evaluation reads only the levels its expression reaches. A path costs the width of the objects it crosses, not the size of the document.
- Expressions that read everything (
**,$string($), a filter over every item) expand every level, as the conversion did. The per-level work must stay within 3% of today’s conversion natively; in WebAssembly, per-level calls replace oneJSON.stringifyand one parse, and the implementation measures both before it is merged. evaluate_documentgains one branch per array or object it reads.- In WebAssembly, an input that
JSON.stringifyrejects fails only when the expression reads the part thatJSON.stringifyrejects, and the input is read during the evaluation rather than before it: a host function that changes the input object can change what is read later. The WebAssembly guide documents both. - The lifetime touches most signatures of the evaluator and the function library, with no change of behaviour; it lands on its own, before the lazy levels.
- Implementation, in order: the
'alifetime alone; the lazy levels with theJsonsource, forevaluateand bindings, with property tests thatevaluateandevaluate_documentagree; theSourcetrait,U1004and the WebAssembly source, with the benchmarks of roadmap 0.8, item 3.
0011 — Host functions call the functions they receive
Status: Accepted
Context
ADR 0006 gave host functions a JSON boundary: a function passed as an argument arrives as
None, so a host function cannot apply it. Rule engines need that for helpers written in the
host language that take a function: $retry($fetch, 3), $memoize(function($k) {...}), a lookup
with a fallback function.
What jsonata-js 2.2.2 does, checked in benches/compare:
- Every function argument is wrapped. A lambda, a built-in function (
$uppercase), a partial application ($substring(?, 1)), a chain ($uppercase ~> $trim) and a host function all arrive as anasyncJavaScript function. Itslengthis 0 and itsarityproperty holds the arity of the wrapped function. Only the arguments themselves are wrapped, not functions inside an array or an object. - Calling it applies the function as
$mapdoes: extra arguments are passed on, and the context value isnull($stringcalled with no argument gives"null"). A lambda keeps the context and the variables of its definition. - It can be called after the host function returns, since it holds its environment.
- Errors reject the promise with their codes (
D3137for$error,T2001). The host function can catch them, or let them through, which ends the evaluation. - Results are internal values. A sequence keeps its
sequenceflag, and a function comes back as the internal object ({"_jsonata_function": true, ...}). - There is no limit. A recursion through a host function
(
$f := function($n){ $apply($f, $n + 1) }) runs until the heap is exhausted.
The constraints of ADR 0006 still hold: HostFunction and Bindings are Send + Sync, the
internal Value is not public, and the untraced evaluator must not get slower. Functions written
against ADR 0006 must keep working.
Decision
- A callable handle, through the call.
HostCall::function(i) -> Option<Callable<'_>>gives argumenti(indexed as in the arguments the function receives, after signature validation) when it is a function, andNoneotherwise. Arguments still arrive asNonein the JSON slice: nothing changes for existing functions. - The boundary stays JSON.
Callable::call(&self, args: &[Option<JsonValue>]) -> Result<Option<JsonValue>>converts the arguments to values and the result back, with the rules of ADR 0006: a sequence is an array or a single value, a function isNone.Callable::arity()gives the arity, like thearityproperty in jsonata-js. - The handle borrows the call. It cannot outlive the host function’s call, so the evaluator it refers to is still running. In Rust the borrow checker enforces this; the WebAssembly bindings enforce it at run time (consequences).
- A call is a callback of the evaluator, as for
$map: the same path native functions use. It applies the function with the contextnull, counts as a level of depth, checks the machine stack, the timeout and the cancellation flag, and evaluates within the same size limits. A recursion through a host function ends withD1011. - Tracing needs no new event. The callback’s events come from the same evaluator, between
the
enterandexitof the host call’s node, so they nest under it. - Errors come back as
Err, with their codes and positions. When the host function returns one, it ends the evaluation like any error from the expression. - Internally,
HostCallgains a reference to the evaluator, the evaluated arguments and the environment of the call, behind a private trait object, so the type keeps its single lifetime. The evaluator is borrowed mutably through aRefCellduring a call. A host function cannot reach its ownHostCallfrom a nested evaluation (the implementation is'static), so the borrow cannot fail; if it ever did, the call returns an error instead of panicking.
Consequences
- Planned differences from jsonata-js, for
compatibility.md:- Results are JSON: no sequence flag and no internal function object reaches the host.
- A callable cannot be called after the host function returns. In WebAssembly, calling it
then throws with a new
Ucode. - In WebAssembly the callable is synchronous: it returns the result, not a promise. Code
written for jsonata-js with
await f(x)keeps working; code that calls.thendoes not. - A recursion through a host function ends with
D1011instead of exhausting memory.
- A host function cannot pass a function to a callable, or receive one as its result: they
cross as
None. A public value type could lift that later, without changing this API. - In WebAssembly, a JavaScript host function receives a JavaScript function for a function
argument, with an
arityproperty, where it receivesundefinedtoday. A function that tested forundefinedsees a different argument: the change is noted in the changelog. - Each call to a callable converts its arguments and its result, as host calls do. Callbacks over large values cost a copy each time.
- The untraced hot path gains nothing beyond passing the evaluator and the environment to the host call: no new event, no check outside host calls.
0012 — The CLI on npm: a launcher package and one package per platform
Status: Accepted
Context
The jlens command comes from the release archives, cargo binstall and cargo install. Many
of the people who write JSONata work in JavaScript projects, where tools are installed with npm
(npm install --save-dev, npx). The npm package jlens-wasm is a library: it has no bin.
Three ways to put the command on npm:
- A second CLI, in JavaScript, over
jlens-wasm. It would have to reproduce the contract of ADR 0008 (envelopes, exit codes, standard output for data, never waiting on a terminal) andjlens lsp, and keep doing so for every change ofjlens-cli. Two implementations of one contract drift. jlens-clicompiled to WASI, run by Node.js. One package for every platform, but Node.js prints an experimental warning for WASI, the interpreter is slower than the native binary, and file and terminal access go through a sandbox that the CLI would have to be adapted to.- The release binary, one package per platform, as esbuild and Biome do: a small package
with the command, whose
optionalDependencieslist one package per platform. npm installs only the one whoseosandcpumatch the machine.
The release builds five binaries (binaries.yml): Linux x86-64 and ARM64 (static, musl), macOS
x86-64 and ARM64, Windows x86-64. They are attested and listed in SHA256SUMS.
Names on npm, checked on 2026-10-13: jlens belongs to an unrelated package (a JSON reader
from 2018, whose command is jl); jlens-cli, jlens-cli-linux-x64, jlens-cli-linux-arm64,
jlens-cli-darwin-x64, jlens-cli-darwin-arm64 and jlens-cli-win32-x64 are free; the scope
@jlens does not exist.
Decision
- Six packages, named after the crate.
jlens-cliholds the commandjlens; itsoptionalDependenciesare the five platform packagesjlens-cli-<os>-<cpu>, withosandcpuas Node.js names them (process.platform,process.arch):linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64. They are unscoped, likejlens-wasm, so no npm organization is needed. The command runs withnpx jlens-clior, once installed,jlens. - The binary is the release binary. Each platform package holds the binary of its archive,
as built and attested by the release, never rebuilt, with
LICENSEand a shortREADME.md. The Linux packages need nolibcfield: the musl binaries are static and run on glibc and musl systems alike. - One version for all. Every package has the workspace version, and
jlens-clipins its platform packages to that exact version. - No install scripts. The command is a small Node.js launcher (
bin/jlens.js): it resolves the binary ofjlens-cli-${process.platform}-${process.arch}, runs it withspawnSyncand standard input, output and error inherited, and exits with the binary’s exit code, or ends with the same signal. The binary sees the real terminal, so ADR 0008 holds through the launcher. When the platform package is missing (an unsupported platform, or an install with--omit=optional), the launcher prints which package it looked for and the other ways to install, on standard error, and exits with code 2. - Generated, then packed in CI. An
xtaskcommand builds the six package directories from the archives: the binaries out of the archives,package.jsonfrom the workspace version. CI packs them and installs the packed packages on Linux, macOS and Windows, then runsjlens --version. - Published by
release.ymlwith provenance, after the binaries and next tojlens-wasm: the platform packages first,jlens-clilast, so that a publishedjlens-clinever points to a missing package. The job skips the packages whose version is already on npm, so re-running it completes a release that failed halfway through.
Consequences
npm install -D jlens-cliandnpx jlens-cligive JavaScript projects the samejlensas every other channel, with no second implementation to keep in step.- Each run of the command starts Node.js before the binary, some tens of milliseconds. Programs
that run
jlensmany times can call the binary directly (node_modules/jlens-cli-<os>-<cpu>/jlens) or install it through another channel. - Windows on ARM64 and other platforms without a release binary get the launcher’s error, as
they get no archive today. A new target in
binaries.ymladds one platform package. - npm and the other channels install the same command in different directories. With more than
one installed, the first on the
PATHruns, whatever its version; inside npm scripts andnpx, a project’s ownjlens-clicomes first. The installation page recommends one channel and shows how to check whichjlensruns. - The release publishes five more packages, and
NPM_TOKENmust be allowed to create them for the first release. - The
jlensname on npm stays with its owner. If it becomes free, it could later become an alias ofjlens-cli.
0013 — The VS Code extension: one package per platform with the release binary
Status: Accepted
Context
jlens lsp is a language server (ADR 0007). Neovim, Helix and Emacs start it from a few lines
of configuration, but VS Code starts language servers only from extensions, and most people who
write JSONata use VS Code. The extension has to answer five questions: where the server comes
from, how the user points it to another jlens, which language it claims, under which name it
is published, and where its code lives.
The server is the jlens binary. The release builds it for five targets (binaries.yml): Linux
x86-64 and ARM64 (static, musl), macOS x86-64 and ARM64, Windows x86-64, attested and listed in
SHA256SUMS. Three ways to give it to the extension:
- A
jlensfound on thePATH. The extension stays small, but nothing works until the user installs the CLI through another channel, and the server’s version drifts from the extension’s. - A download at first start, from the GitHub release. The extension would need the network on first use (not always there, often behind a proxy), and would have to verify the archive itself before running it.
- One VSIX per platform with the release binary, as rust-analyzer does. Since VS Code 1.61,
the Visual Studio Marketplace and Open VSX serve each platform its own package (
vsce package --target), and a package without a target to the platforms that have none.
VS Code’s targets include alpine-x64 and alpine-arm64. The musl binaries are static, so the
two Linux archives serve both the linux- and the alpine- targets.
Other JSONata extensions, checked on 2026-10-14: on the Marketplace,
bigbug.vscode-language-jsonata (a grammar, about 6,000 installs, last updated in 2023),
nawed-diwan.jsonata-intellisense and Fitmavincent.jsonata-validator; on Open VSX, none. All
three use the language id jsonata, the extension .jsonata and the grammar scope
source.jsonata, as the editors guide does for Neovim and Helix.
Names, checked on 2026-10-14: the publisher thkprado exists on neither registry, and neither
does the extension thkprado.jlens.
Open VSX publishes with an access token of an Eclipse account (ovsx publish). The Marketplace
publishes with a personal access token of Azure DevOps, but retires global tokens on 2026-12-01
and recommends a Microsoft Entra ID identity instead (vsce publish --azure-credential): a
managed identity, which is an Azure resource and needs an Azure subscription.
Decision
- One VSIX per release target, with the release binary.
linux-x64,linux-arm64,alpine-x64,alpine-arm64,darwin-x64,darwin-arm64andwin32-x64, each holding thejlensbinary of its archive, as built and attested by the release, never rebuilt. One more VSIX, without a target and without a binary, serves the other platforms (Windows on ARM64, 32-bit ARM Linux) and uses thejlenson thePATH. No download at first start. - The server is found in this order: the setting
jlens.server.path, then the bundled binary, thenjlenson thePATH. The setting has themachinescope and is restricted in untrusted workspaces, so that a repository’s settings cannot choose which program runs. When no server is found, the extension says so once, with a link to the installation page, and leaves highlighting working. The server’s version, from itsinitializeanswer, goes to the output channel. - The language is
jsonata, with the extension.jsonata, the aliasJSONataand the grammar scopesource.jsonata, the same as the other extensions and editors. A file opened in VS Code has one language id, so a different id would split JSONata files between extensions.jlens.jsonis checked through a JSON Schema contributed withjsonValidation. - The extension is
thkprado.jlens, namedjlensand displayed as “jlens — JSONata”, on both registries: the publisherthkpradoon the Marketplace and the namespacethkpradoon Open VSX, created by the maintainer. - One version for all. The extension has the workspace version and is published by
release.ymlafter the binaries, as the npm packages are (ADR 0012), skipping a version already published, so re-running the job completes a release that failed halfway through. - The Marketplace credential is chosen with the publishing job (roadmap 0.15 #5), among:
an Entra ID managed identity federated with the release workflow (no long-lived secret, but
an Azure subscription); a personal access token, which works only until 2026-12-01; or no
credential, with the maintainer uploading the VSIX files attached to the GitHub release.
Chosen on 2026-10-14: an Entra ID identity federated with the GitHub environment
vscodeof the release job. It became an application (a service principal) rather than a managed identity: it needs no subscription, so it outlives a free one. Open VSX is published to with a token for the first version, which only a token can publish, then through trusted publishing (the run’s OIDC token), so that neither registry keeps a long-lived secret.CONTRIBUTING.mdsays how to set both up. - The code lives in
editors/vscode/, outside the Cargo workspace:package.json, the TypeScript client overvscode-languageclient, bundled into one file with esbuild, the TextMate grammar and the language configuration, and the extension tests (@vscode/test-electron). Acargo xtask vscodecommand builds the VSIX files from the release archives, ascargo xtask npm-clibuilds the npm packages.
Consequences
- Installing the extension is enough on the five release platforms: no other step, and the server always has the extension’s version. Other platforms install the CLI another way.
- Each release publishes eight VSIX files to two registries. Seven of them hold a binary of a few megabytes; the alpine packages repeat the Linux ones.
- With
jlens.server.pathor ajlensfrom thePATH, the server’s version may differ from the extension’s. The output channel shows it. - With another JSONata extension installed, VS Code uses both: two grammars for the same scope, of which one wins, and diagnostics from each server. The extension’s README says to disable the other’s language features.
- The release needs an Entra ID tenant for the application, which an Azure sign-up creates,
and a GitHub environment whose rules accept only version tags. Only a workflow run can sign
in as the application, so a small workflow (
marketplace-identity.yml) reads the ID the publisher takes as a member. - VS Code for the Web (the
webtarget) is left out. The WebAssembly package offers the same language service, and could serve it later.