Jetro is a library for querying, transforming, and comparing JSON. It provides a compact expression language, a bytecode VM with caching.
Jetro has minimal dependencies and operates on top of serde_json.
This project is in experimental phase — significant changes may occur.
use jetro::Jetro;
use serde_json::json;
let j = Jetro::new(json!({
"store": {
"books": [
{"title": "Dune", "price": 12.99},
{"title": "Foundation", "price": 9.99}
]
}
}));
let titles = j.collect("$.store.books.filter(price > 10).map(title)").unwrap();
assert_eq!(titles, json!(["Dune"]));One-shot evaluation (tree-walker, no VM caching):
let result = jetro::query("$.store.books.len()", &doc).unwrap();Progressive walk through the Rust API over one rich payload — simple field access through compile-time macros. Every snippet runs against this document:
use jetro::prelude::*;
let doc = json!({
"store": {
"name": "Nova Books",
"currency": "USD",
"books": [
{"id": "b1", "title": "Dune", "author": "Frank Herbert", "price": 12.99, "year": 1965, "tags": ["sci-fi","classic"], "stock": 14, "ratings": [5,5,4,5,3]},
{"id": "b2", "title": "Foundation", "author": "Isaac Asimov", "price": 9.99, "year": 1951, "tags": ["sci-fi","classic"], "stock": 0, "ratings": [5,5,5]},
{"id": "b3", "title": "Neuromancer", "author": "William Gibson", "price": 19.50, "year": 1984, "tags": ["cyberpunk"], "stock": 3, "ratings": [4,5,4,5]},
{"id": "b4", "title": "The Hobbit", "author": "J.R.R. Tolkien", "price": 14.25, "year": 1937, "tags": ["fantasy","classic"], "stock": 22, "ratings": [5,5,5,5,4,5]},
{"id": "b5", "title": "Hyperion", "author": "Dan Simmons", "price": 18.00, "year": 1989, "tags": ["sci-fi"], "stock": 7, "ratings": [5,5,4]}
],
"customers": [
{"id": "c1", "name": "Ada Lovelace", "tier": "gold", "credits": 250.0},
{"id": "c2", "name": "Alan Turing", "tier": "silver", "credits": 40.0},
{"id": "c3", "name": "Grace Hopper", "tier": "gold", "credits": 180.0}
],
"orders": [
{"id": "o1", "customer_id": "c1", "items": [{"book_id":"b1","qty":2},{"book_id":"b4","qty":1}], "status": "paid"},
{"id": "o2", "customer_id": "c2", "items": [{"book_id":"b3","qty":1}], "status": "pending"},
{"id": "o3", "customer_id": "c1", "items": [{"book_id":"b5","qty":3}], "status": "paid"}
]
}
});
let j = Jetro::new(doc.clone());Jetro holds a thread-local VM; collect() compiles each unique expression once (then reuses the bytecode + pointer cache), so rerunning the same or similar query is essentially free.
assert_eq!(j.collect("$.store.name")?, json!("Nova Books"));
assert_eq!(j.collect("$.store.books[0].author")?, json!("Frank Herbert"));
assert_eq!(j.collect("$.store.books[-1].title")?, json!("Hyperion"));let cheap = j.collect("$.store.books.filter(price < 15).map(title)")?;
// ["Dune", "Foundation", "The Hobbit"]let in_stock_total = j.collect("$.store.books.filter(stock > 0).sum(price)")?;
let mean_price = j.collect("$.store.books.map(price).avg()")?;
let well_rated = j.collect("$.store.books.filter(ratings.avg() >= 4.5).len()")?;Build a new object per element:
let summary = j.collect(r#"
$.store.books.map({
title,
mean_rating: ratings.avg(),
in_stock: stock > 0
})
"#)?;let scifi_by_year = j.collect(r#"
$.store.books
.filter("sci-fi" in tags)
.sort_by(year)
.map({year, title})
"#)?;
let by_first_tag = j.collect("$.store.books.group_by(tags[0])")?;let out_of_stock = j.collect("[b.title for b in $.store.books if b.stock == 0]")?;
// ["Foundation"]
let id_to_title = j.collect("{b.id: b.title for b in $.store.books}")?;let headline = j.collect(r#"
let top = $.store.books.sort_by(ratings.avg()).reverse()[0] in
f"Top-rated: {top.title} ({top.ratings.avg():.2f})"
"#)?;
// "Top-rated: The Hobbit (4.83)"| passes the left value through the right expression as @:
let avg = j.collect("$.store.books.map(price) | @.avg()")?;
let shout = j.collect("$.store.books[0].title | upper")?; // "DUNE"In-place updates that compose like any other expression:
let discounted = j.collect(r#"
patch $ {
store.books[*].price: @ * 0.9,
store.books[* if stock == 0].available: false,
store.books[* if year < 1960].vintage: true
}
"#)?;Patch-field syntax: path: value when predicate?. Paths start with an identifier and may include .field, [n], [*], [* if pred], and ..field. DELETE removes the matched key.
Register Rust code as a first-class method:
use jetro::{Method, MethodRegistry};
use jetro::eval::{Env, Val};
use jetro::EvalError;
struct Percentile;
impl Method for Percentile {
fn name(&self) -> &str { "percentile" }
fn call(&self, subject: &Val, args: &[Val], _env: &Env) -> Result<Val, EvalError> {
let xs = subject.as_array().ok_or_else(|| EvalError("not an array".into()))?;
let p = args.first().and_then(|v| v.as_f64()).unwrap_or(50.0) / 100.0;
let mut v: Vec<f64> = xs.iter().filter_map(|x| x.as_f64()).collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let i = ((v.len() as f64 - 1.0) * p).round() as usize;
Ok(Val::from(v[i]))
}
}
let mut reg = MethodRegistry::new();
reg.register(std::sync::Arc::new(Percentile));
let p90 = jetro::query_with("$.store.books.map(price).percentile(90)", &doc, reg.into())?;Expressions carry a phantom return type:
let titles: Expr<Vec<String>> = Expr::new("$.store.books.map(title)")?;
let upper: Expr<Vec<String>> = Expr::new("@.map(upper)")?;
// `|` composes expressions end-to-end
let pipeline = titles | upper;
let shouted = pipeline.eval(&doc)?;Every query through Jetro::collect or VM::run_str hits two caches:
- Program cache:
expr string → Arc<[Opcode]>. Each unique expression is parsed + compiled exactly once. - Pointer cache: resolved field paths keyed by
(program_id, doc_hash). Structural queries short-circuit straight to the leaves on repeat calls.
Rerunning a workload over fresh documents of the same shape is effectively free after the first call. For one-shot queries use jetro::query(expr, &doc) — it skips the VM entirely.
$..field walks every descendant level. Combine with filters and projections:
let stats = j.collect(r#"
let prices = $..price.filter(@ kind number) in
{min: prices.min(), max: prices.max(), sum: prices.sum(), n: prices.len()}
"#)?;
let keyed = j.collect(r#"
$..id.filter(@ kind string).map({id: @, kind: @.slice(0, 1)})
"#)?;Two generators produce the cartesian product; the if filter keeps only matching pairs:
let receipts = j.collect(r#"
[
{
order: o.id,
buyer: ($.store.customers.filter(id == o.customer_id))[0].name,
title: ($.store.books.filter(id == li.book_id))[0].title,
qty: li.qty,
subtotal: ($.store.books.filter(id == li.book_id))[0].price * li.qty
}
for o in $.store.orders
for li in o.items
if o.status == "paid"
]
"#)?;Three nested loops + three lookups folded into one compiled program. Hits the pointer cache on repeat calls.
One patch block stacks path-scoped clauses. Each clause runs on the patched result of the previous one:
let restocked = j.collect(r#"
let tagged = patch $ {
store.books[* if stock < 5].reorder: true,
store.books[* if tags.includes("classic")].badge: "Vintage",
store.books[0].title: @.upper()
}
in patch tagged {
store.books[* if stock == 0]: DELETE
}
"#)?;Two filter layers: [* if pred] filters per element against the element's own fields; when pred on a field is evaluated against root $. Chain blocks with let ... in patch ... when a later mutation needs to see earlier output.
Jetro gives each thread its own VM. Engine is a Send + Sync handle around a Mutex<VM> so one warm cache services many workers:
use jetro::prelude::*;
use std::sync::Arc;
let engine = Engine::new(); // Arc<Engine>
let shared = Arc::clone(&engine);
std::thread::spawn(move || {
// Compile + pointer caches shared with the spawner.
shared.run("$.store.books.len()", &doc).unwrap();
}).join().unwrap();First call compiles and fills both caches; every subsequent thread hitting the same expression skips to execution.
Enable the macros feature. Expressions get a full pest parse at compile time, with errors pointing at the exact macro call-site. Returns a typed Expr<Value>:
use jetro::jetro;
let avg_price = jetro!("$.store.books.map(price).avg()");
let n_classic = jetro!(r#"$.store.books.filter("classic" in tags).len()"#);
assert_eq!(avg_price.eval_raw(&doc)?, json!(14.946));
assert_eq!(n_classic.eval_raw(&doc)?, json!(3));Unbalanced parens, unterminated strings, unknown operators — build failures, not runtime errors.
Pair a type with a fixed set of named expressions. The derive emits EXPRS, exprs(), and names(). Each #[expr(...)] line is grammar-checked at compile time, so an invalid expression in a schema never ships.
use jetro::JetroSchema;
#[derive(JetroSchema)]
#[expr(titles = "$.store.books.map(title)")]
#[expr(mean_rating = "$.store.books.map(ratings.avg()).avg()")]
#[expr(out_of_stock = "[b.title for b in $.store.books if b.stock == 0]")]
struct StoreView;
assert_eq!(StoreView::names(), &["titles", "mean_rating", "out_of_stock"]);
for (name, src) in StoreView::exprs() {
let val = jetro::query(src, &doc)?;
println!("{name}: {val}");
}| Feature | Pulls in | Unlocks |
|---|---|---|
macros |
jetro-macros companion crate |
jetro!(...) macro, #[derive(JetroSchema)] |
Cargo.toml:
[dependencies]
jetro = { version = "0.3", features = ["macros"] }| Token | Meaning |
|---|---|
$ |
Document root |
@ |
Current item (lambdas, pipes, comprehensions, patch paths) |
$.field // child field
$.a.b.c // nested
$.user?.name // null-safe field
$.items[0] // array index
$.items[-1] // last
$.items[2:5] // slice [2, 5)
$.items[2:] // tail
$.items[:5] // head
$..title // recursive descent
null true false
42 3.14
"hello" 'world'
f"Hello {$.user.name}!" // f-string with interpolation
f"{$.price:.2f}" // format spec
f"{$.name | upper}" // pipe transform
== != < <= > >= // comparison
~= // fuzzy match (case-insensitive substring)
+ - * / % // arithmetic
and or not // logical
$.field ?| "default" // null coalescing
All operations are dot-called methods:
$.books.filter(price > 10)
$.books.map(title)
$.books.sum(price)
$.books.filter(price > 10).count()
$.books.sort_by(price).reverse()
$.users.group_by(tier)
$.items.map({name, total: price * qty})
[book.title for book in $.books if book.price > 10]
{user.id: user.name for user in $.users if user.active}
let top = $.books.filter(price > 100) in
{count: top.len(), titles: top.map(title)}
$.books.filter(lambda b: b.tags.includes("sci-fi"))
$.items.filter(price kind number and price > 0)
$.data.filter(deleted_at kind null)
| passes the left value through the right expression as @:
$.name | upper
$.price | (@ * 1.1)
In-place updates with block-form syntax. Each clause matches a path and produces either a replacement value or DELETE:
patch $ {
books[*].price: @ * 1.1,
books[* if stock == 0]: DELETE,
meta.updated_at: now()
}
Everything below is shipped by the jetro-core crate and re-exported here.
grammar.pest+parser.rs— pest PEG grammar →ast::Expreval/mod.rs— tree-walking evaluator (reference semantics)eval/value.rs—Valtype:Arc-wrapped compound nodes, O(1) cloneseval/func_*.rs— built-in methods grouped by domain (strings, arrays, objects, paths, aggregates, csv)eval/methods.rs—Methodtrait +MethodRegistryfor user-registered methodsvm.rs— bytecode compiler + stack machine; 10 peephole passes; compile and pointer cachesgraph.rs— multi-document query primitive (merges named nodes into a virtual root)analysis.rs/schema.rs/plan.rs/cfg.rs/ssa.rs— optional IRs (type/nullness/cardinality, shape inference, logical plan, CFG, SSA numbering)
See jetro-core/README.md for a full technical walkthrough: AST variants, opcode set, peephole passes, cache invariants.
jetro-core— engine (this crate re-exports it)jetro-macros—jetro!()+#[derive(JetroSchema)](feature = "macros")
MIT. See LICENSE.