diff --git a/Cargo.lock b/Cargo.lock
index 255c7cac3..e1bb2fcd2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5795,6 +5795,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "peresil"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57"
+
[[package]]
name = "pest"
version = "0.3.3"
@@ -8373,6 +8379,27 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "sxd-document"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94d82f37be9faf1b10a82c4bd492b74f698e40082f0f40de38ab275f31d42078"
+dependencies = [
+ "peresil",
+ "typed-arena",
+]
+
+[[package]]
+name = "sxd-xpath"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36e39da5d30887b5690e29de4c5ebb8ddff64ebd9933f98a01daaa4fd11b36ea"
+dependencies = [
+ "peresil",
+ "quick-error",
+ "sxd-document",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -9730,6 +9757,12 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "typed-arena"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d"
+
[[package]]
name = "typedmap"
version = "0.6.0"
@@ -11173,6 +11206,7 @@ dependencies = [
"tokio",
"yaak-core",
"yaak-crypto",
+ "yaak-filter",
"yaak-http",
"yaak-models",
"yaak-plugins",
@@ -11240,6 +11274,7 @@ dependencies = [
"yaak-common",
"yaak-core",
"yaak-crypto",
+ "yaak-filter",
"yaak-fonts",
"yaak-git",
"yaak-grpc",
@@ -11390,6 +11425,17 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "yaak-filter"
+version = "0.1.0"
+dependencies = [
+ "fancy-regex",
+ "indexmap 2.14.0",
+ "serde_json",
+ "sxd-document",
+ "sxd-xpath",
+]
+
[[package]]
name = "yaak-fonts"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 3780f5343..fbc8e874d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ members = [
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
+ "crates/yaak-filter",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
@@ -86,6 +87,7 @@ yaak = { path = "crates/yaak" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
+yaak-filter = { path = "crates/yaak-filter" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
diff --git a/crates-cli/yaak-cli/tests/plugin_filter.rs b/crates-cli/yaak-cli/tests/plugin_filter.rs
new file mode 100644
index 000000000..077baf11c
--- /dev/null
+++ b/crates-cli/yaak-cli/tests/plugin_filter.rs
@@ -0,0 +1,85 @@
+mod common;
+
+use common::http_server::TestHttpServer;
+use common::{cli_cmd, query_manager, seed_workspace};
+use predicates::str::contains;
+use std::fs;
+use tempfile::TempDir;
+use yaak_models::models::{HttpRequest, HttpRequestHeader};
+use yaak_models::util::UpdateSource;
+
+/// A template function that runs `ctx.filter` and returns the host's reply as JSON.
+const PROBE_PLUGIN: &str = r#"exports.plugin = {
+ templateFunctions: [{
+ name: "filter_probe",
+ args: [
+ { type: "text", name: "language" },
+ { type: "text", name: "text" },
+ { type: "text", name: "query" },
+ ],
+ async onRender(ctx, args) {
+ const { language, text, query } = args.values;
+ const result = language === "xpath"
+ ? await ctx.filter.xPath(text, query)
+ : await ctx.filter.jsonPath(text, query);
+ return JSON.stringify(result);
+ },
+ }],
+};"#;
+
+/// The plugin runtime, the CLI's plugin host, and yaak-filter, end to end: the
+/// rendered header values are what the host replied to the plugin.
+#[test]
+fn plugins_filter_text_through_the_host() {
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
+ let data_dir = temp_dir.path();
+
+ // Bundled plugins are extracted next to this on launch; extras are kept.
+ let probe_dir = data_dir.join("vendored-plugins").join("filter-probe");
+ fs::create_dir_all(probe_dir.join("build")).expect("Failed to create probe plugin dir");
+ fs::write(probe_dir.join("package.json"), r#"{"name":"@yaak/filter-probe","version":"0.0.0"}"#)
+ .expect("Failed to write probe package.json");
+ fs::write(probe_dir.join("build").join("index.js"), PROBE_PLUGIN)
+ .expect("Failed to write probe plugin");
+
+ seed_workspace(data_dir, "wk_test");
+ let server = TestHttpServer::spawn_ok("ok");
+ let header = |name: &str, value: &str| HttpRequestHeader {
+ enabled: true,
+ name: name.to_string(),
+ value: value.to_string(),
+ id: None,
+ };
+ let request = HttpRequest {
+ id: "rq_filter".to_string(),
+ workspace_id: "wk_test".to_string(),
+ method: "GET".to_string(),
+ url: server.url.clone(),
+ headers: vec![
+ header(
+ "x-json",
+ r#"${[ filter_probe(language='jsonpath', text='{"a":[1,2]}', query='$.a[1]') ]}"#,
+ ),
+ header(
+ "x-xml",
+ "${[ filter_probe(language='xpath', text='x', query='//b') ]}",
+ ),
+ header(
+ "x-bad",
+ "${[ filter_probe(language='jsonpath', text='{}', query='$[?(@ ==)]') ]}",
+ ),
+ ],
+ ..Default::default()
+ };
+ query_manager(data_dir)
+ .with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
+ .expect("Failed to seed request");
+
+ cli_cmd(data_dir)
+ .args(["--verbose", "request", "send", "rq_filter"])
+ .assert()
+ .success()
+ .stdout(contains(r#"> x-json: {"content":"[\n 2\n]"}"#))
+ .stdout(contains(r#"> x-xml: {"content":"x"}"#))
+ .stdout(contains(r#"> x-bad: {"content":"","error":"Invalid filter: "#));
+}
diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml
index dd3c2cd0e..ed7547c6b 100644
--- a/crates-tauri/yaak-app-client/Cargo.toml
+++ b/crates-tauri/yaak-app-client/Cargo.toml
@@ -83,6 +83,7 @@ yaak-core = { workspace = true }
yaak = { workspace = true }
yaak-commands = { workspace = true }
yaak-crypto = { workspace = true }
+yaak-filter = { workspace = true }
yaak-fonts = { workspace = true }
yaak-git = { workspace = true }
yaak-grpc = { workspace = true }
diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs
index df5778b7f..e53668207 100644
--- a/crates-tauri/yaak-app-client/src/lib.rs
+++ b/crates-tauri/yaak-app-client/src/lib.rs
@@ -1004,10 +1004,11 @@ async fn cmd_http_response_body(
.ok_or(GenericError("Failed to find response body".to_string()))?;
match filter {
- Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
- .await?
- .filter_data(&window.plugin_context(), filter, &body, content_type)
- .await?),
+ Some(filter) if !filter.is_empty() => {
+ let language = yaak_filter::Language::for_content_type(content_type);
+ let result = yaak_filter::filter(language, &body, filter);
+ Ok(FilterResponse { content: result.content, error: result.error })
+ }
_ => Ok(FilterResponse { content: body, error: None }),
}
}
diff --git a/crates/yaak-filter/Cargo.toml b/crates/yaak-filter/Cargo.toml
new file mode 100644
index 000000000..052e35201
--- /dev/null
+++ b/crates/yaak-filter/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "yaak-filter"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+fancy-regex = "0.17"
+indexmap = "2"
+sxd-document = "0.3.2"
+sxd-xpath = "0.4.2"
+
+[dev-dependencies]
+serde_json = { workspace = true, features = ["preserve_order"] }
diff --git a/crates/yaak-filter/src/jsonpath/json.rs b/crates/yaak-filter/src/jsonpath/json.rs
new file mode 100644
index 000000000..5f6b0420f
--- /dev/null
+++ b/crates/yaak-filter/src/jsonpath/json.rs
@@ -0,0 +1,423 @@
+//! The response document as `JSON.parse` builds it, and `JSON.stringify(_, null, 2)`.
+
+use super::value::JsValue;
+use indexmap::IndexMap;
+
+const MAX_DEPTH: usize = 128;
+
+#[derive(Debug)]
+pub enum Json {
+ Null,
+ Bool(bool),
+ Number(f64),
+ String(String),
+ Array(Vec),
+ Object(IndexMap),
+}
+
+pub fn parse(text: &str) -> Result {
+ let mut parser = Parser { bytes: text.as_bytes(), text, pos: 0 };
+ parser.skip_ws();
+ let value = parser.value(0)?;
+ parser.skip_ws();
+ if parser.pos < parser.bytes.len() {
+ return Err(parser.error("trailing characters"));
+ }
+ Ok(value)
+}
+
+struct Parser<'t> {
+ bytes: &'t [u8],
+ text: &'t str,
+ pos: usize,
+}
+
+impl Parser<'_> {
+ fn error(&self, message: &str) -> String {
+ let mut end = self.pos.min(self.text.len());
+ while !self.text.is_char_boundary(end) {
+ end -= 1;
+ }
+ let before = &self.text[..end];
+ let line = before.matches('\n').count() + 1;
+ let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1;
+ format!("{message} at line {line} column {column}")
+ }
+
+ fn peek(&self) -> Option {
+ self.bytes.get(self.pos).copied()
+ }
+
+ fn skip_ws(&mut self) {
+ while let Some(b' ' | b'\t' | b'\n' | b'\r') = self.peek() {
+ self.pos += 1;
+ }
+ }
+
+ fn expect_word(&mut self, word: &str, value: Json) -> Result {
+ if self.text[self.pos..].starts_with(word) {
+ self.pos += word.len();
+ Ok(value)
+ } else {
+ Err(self.error("expected value"))
+ }
+ }
+
+ fn value(&mut self, depth: usize) -> Result {
+ if depth > MAX_DEPTH {
+ return Err(self.error("recursion limit exceeded"));
+ }
+ match self.peek() {
+ None => Err(self.error("EOF while parsing a value")),
+ Some(b'n') => self.expect_word("null", Json::Null),
+ Some(b't') => self.expect_word("true", Json::Bool(true)),
+ Some(b'f') => self.expect_word("false", Json::Bool(false)),
+ Some(b'"') => Ok(Json::String(self.string()?)),
+ Some(b'[') => self.array(depth),
+ Some(b'{') => self.object(depth),
+ Some(b'-' | b'0'..=b'9') => self.number(),
+ Some(_) => Err(self.error("expected value")),
+ }
+ }
+
+ fn array(&mut self, depth: usize) -> Result {
+ self.pos += 1;
+ let mut items = Vec::new();
+ self.skip_ws();
+ if self.peek() == Some(b']') {
+ self.pos += 1;
+ return Ok(Json::Array(items));
+ }
+ loop {
+ self.skip_ws();
+ items.push(self.value(depth + 1)?);
+ self.skip_ws();
+ match self.peek() {
+ Some(b',') => self.pos += 1,
+ Some(b']') => {
+ self.pos += 1;
+ return Ok(Json::Array(items));
+ }
+ _ => return Err(self.error("expected `,` or `]`")),
+ }
+ }
+ }
+
+ fn object(&mut self, depth: usize) -> Result {
+ self.pos += 1;
+ let mut map = IndexMap::new();
+ self.skip_ws();
+ if self.peek() == Some(b'}') {
+ self.pos += 1;
+ return Ok(Json::Object(map));
+ }
+ loop {
+ self.skip_ws();
+ if self.peek() != Some(b'"') {
+ return Err(self.error("key must be a string"));
+ }
+ let key = self.string()?;
+ self.skip_ws();
+ if self.peek() != Some(b':') {
+ return Err(self.error("expected `:`"));
+ }
+ self.pos += 1;
+ self.skip_ws();
+ let value = self.value(depth + 1)?;
+ map.insert(key, value);
+ self.skip_ws();
+ match self.peek() {
+ Some(b',') => self.pos += 1,
+ Some(b'}') => {
+ self.pos += 1;
+ return Ok(Json::Object(js_key_order(map)));
+ }
+ _ => return Err(self.error("expected `,` or `}`")),
+ }
+ }
+ }
+
+ fn number(&mut self) -> Result {
+ let start = self.pos;
+ if self.peek() == Some(b'-') {
+ self.pos += 1;
+ }
+ match self.peek() {
+ Some(b'0') => self.pos += 1,
+ Some(b'1'..=b'9') => self.digits(),
+ _ => return Err(self.error("invalid number")),
+ }
+ if self.peek() == Some(b'.') {
+ self.pos += 1;
+ if !matches!(self.peek(), Some(b'0'..=b'9')) {
+ return Err(self.error("invalid number"));
+ }
+ self.digits();
+ }
+ if let Some(b'e' | b'E') = self.peek() {
+ self.pos += 1;
+ if let Some(b'+' | b'-') = self.peek() {
+ self.pos += 1;
+ }
+ if !matches!(self.peek(), Some(b'0'..=b'9')) {
+ return Err(self.error("invalid number"));
+ }
+ self.digits();
+ }
+ let number =
+ self.text[start..self.pos].parse::().map_err(|e| self.error(&e.to_string()))?;
+ Ok(Json::Number(number))
+ }
+
+ fn digits(&mut self) {
+ while let Some(b'0'..=b'9') = self.peek() {
+ self.pos += 1;
+ }
+ }
+
+ fn hex4(&mut self) -> Result {
+ let hex =
+ self.text.get(self.pos..self.pos + 4).ok_or_else(|| self.error("invalid escape"))?;
+ let unit = u16::from_str_radix(hex, 16).map_err(|_| self.error("invalid escape"))?;
+ if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return Err(self.error("invalid escape"));
+ }
+ self.pos += 4;
+ Ok(unit)
+ }
+
+ fn string(&mut self) -> Result {
+ self.pos += 1;
+ let mut out = String::new();
+ loop {
+ let run = self.pos;
+ while let Some(b) = self.peek() {
+ if b == b'"' || b == b'\\' || b < 0x20 {
+ break;
+ }
+ self.pos += 1;
+ }
+ out.push_str(&self.text[run..self.pos]);
+ match self.peek() {
+ None => return Err(self.error("EOF while parsing a string")),
+ Some(b'"') => {
+ self.pos += 1;
+ return Ok(out);
+ }
+ Some(b'\\') => {
+ self.pos += 1;
+ let escape =
+ self.peek().ok_or_else(|| self.error("EOF while parsing a string"))?;
+ self.pos += 1;
+ match escape {
+ b'"' => out.push('"'),
+ b'\\' => out.push('\\'),
+ b'/' => out.push('/'),
+ b'b' => out.push('\u{8}'),
+ b'f' => out.push('\u{c}'),
+ b'n' => out.push('\n'),
+ b'r' => out.push('\r'),
+ b't' => out.push('\t'),
+ b'u' => {
+ let unit = self.hex4()?;
+ let mut units = vec![unit];
+ if (0xD800..0xDC00).contains(&unit)
+ && self.text[self.pos..].starts_with("\\u")
+ {
+ let save = self.pos;
+ self.pos += 2;
+ let low = self.hex4()?;
+ if (0xDC00..0xE000).contains(&low) {
+ units.push(low);
+ } else {
+ self.pos = save;
+ }
+ }
+ out.push_str(&String::from_utf16_lossy(&units));
+ }
+ _ => return Err(self.error("invalid escape")),
+ }
+ }
+ Some(_) => return Err(self.error("control character in string")),
+ }
+ }
+ }
+}
+
+/// `JSON.parse` gives array-index keys first, ascending, then the rest in document order.
+fn js_key_order(map: IndexMap) -> IndexMap {
+ if !map.keys().any(|k| array_index(k).is_some()) {
+ return map;
+ }
+ let (mut indexes, rest): (Vec<_>, Vec<_>) =
+ map.into_iter().partition(|(k, _)| array_index(k).is_some());
+ indexes.sort_by_key(|(k, _)| array_index(k));
+ indexes.into_iter().chain(rest).collect()
+}
+
+/// The value of a canonical array-index string ("0", "17", never "07" or "-1").
+pub fn array_index(key: &str) -> Option {
+ if key.is_empty()
+ || (key.len() > 1 && key.starts_with('0'))
+ || !key.bytes().all(|b| b.is_ascii_digit())
+ {
+ return None;
+ }
+ key.parse::().ok().filter(|i| *i < u32::MAX)
+}
+
+/// `JSON.stringify(values, null, 2)`.
+pub fn stringify_results(values: &[JsValue<'_>]) -> String {
+ let mut out = String::new();
+ write_array(&mut out, values.iter(), values.is_empty(), 0);
+ out
+}
+
+fn indent(out: &mut String, depth: usize) {
+ out.push('\n');
+ for _ in 0..depth {
+ out.push_str(" ");
+ }
+}
+
+fn write_array<'v, 'a: 'v>(
+ out: &mut String,
+ items: impl Iterator- >,
+ empty: bool,
+ depth: usize,
+) {
+ if empty {
+ out.push_str("[]");
+ return;
+ }
+ out.push('[');
+ for (i, item) in items.enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ indent(out, depth + 1);
+ if !write_js(out, item, depth + 1) {
+ out.push_str("null");
+ }
+ }
+ indent(out, depth);
+ out.push(']');
+}
+
+/// Writes the value and returns true, or false for what `JSON.stringify` drops.
+fn write_js(out: &mut String, value: &JsValue<'_>, depth: usize) -> bool {
+ match value {
+ JsValue::Undefined | JsValue::Function(_) => return false,
+ JsValue::Null => out.push_str("null"),
+ JsValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
+ JsValue::Number(n) => write_number(out, *n),
+ JsValue::String(s) => write_string(out, s),
+ JsValue::Doc(json) => write_json(out, json, depth),
+ JsValue::Array(array) => {
+ write_array(out, array.items.iter(), array.items.is_empty(), depth)
+ }
+ JsValue::Regex(_) => out.push_str("{}"),
+ }
+ true
+}
+
+fn write_json(out: &mut String, json: &Json, depth: usize) {
+ match json {
+ Json::Null => out.push_str("null"),
+ Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
+ Json::Number(n) => write_number(out, *n),
+ Json::String(s) => write_string(out, s),
+ Json::Array(items) => {
+ if items.is_empty() {
+ out.push_str("[]");
+ return;
+ }
+ out.push('[');
+ for (i, item) in items.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ indent(out, depth + 1);
+ write_json(out, item, depth + 1);
+ }
+ indent(out, depth);
+ out.push(']');
+ }
+ Json::Object(map) => {
+ if map.is_empty() {
+ out.push_str("{}");
+ return;
+ }
+ out.push('{');
+ for (i, (key, item)) in map.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ indent(out, depth + 1);
+ write_string(out, key);
+ out.push_str(": ");
+ write_json(out, item, depth + 1);
+ }
+ indent(out, depth);
+ out.push('}');
+ }
+ }
+}
+
+fn write_number(out: &mut String, n: f64) {
+ if n.is_finite() {
+ out.push_str(&number_to_string(n));
+ } else {
+ out.push_str("null");
+ }
+}
+
+fn write_string(out: &mut String, s: &str) {
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\u{8}' => out.push_str("\\b"),
+ '\u{c}' => out.push_str("\\f"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
+ c => out.push(c),
+ }
+ }
+ out.push('"');
+}
+
+/// `Number.prototype.toString()` for radix 10.
+pub fn number_to_string(n: f64) -> String {
+ if n.is_nan() {
+ return "NaN".to_string();
+ }
+ if n == 0.0 {
+ return "0".to_string();
+ }
+ if n.is_infinite() {
+ return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
+ }
+ let sign = if n < 0.0 { "-" } else { "" };
+ let sci = format!("{:e}", n.abs());
+ let (mantissa, exponent) = sci.split_once('e').unwrap_or((&sci, "0"));
+ let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
+ let k = digits.len() as i32;
+ let n_exp = exponent.parse::().unwrap_or(0) + 1;
+ let body = if k <= n_exp && n_exp <= 21 {
+ format!("{digits}{}", "0".repeat((n_exp - k) as usize))
+ } else if 0 < n_exp && n_exp <= 21 {
+ format!("{}.{}", &digits[..n_exp as usize], &digits[n_exp as usize..])
+ } else if -6 < n_exp && n_exp <= 0 {
+ format!("0.{}{digits}", "0".repeat((-n_exp) as usize))
+ } else {
+ let e = n_exp - 1;
+ let e_sign = if e < 0 { "-" } else { "+" };
+ let rest = if k > 1 { format!(".{}", &digits[1..]) } else { String::new() };
+ format!("{}{rest}e{e_sign}{}", &digits[..1], e.abs())
+ };
+ format!("{sign}{body}")
+}
diff --git a/crates/yaak-filter/src/jsonpath/mod.rs b/crates/yaak-filter/src/jsonpath/mod.rs
new file mode 100644
index 000000000..82f7b5736
--- /dev/null
+++ b/crates/yaak-filter/src/jsonpath/mod.rs
@@ -0,0 +1,798 @@
+//! A port of jsonpath-plus 11.0.0 (`JSONPath({ path, json })` with its default
+//! options and safe evaluator), so expressions written for the JavaScript
+//! filter plugin keep their results. Where it differs on purpose: quoted
+//! bracket names are always literal keys, and some RFC 9535 syntax that
+//! jsonpath-plus finds nothing for works (`[-1]`, `[::-1]`, `[?@.a > 1]`,
+//! `length()`, `count()`, `match()`, `search()`, `value()`).
+
+mod json;
+mod path;
+mod regex;
+mod rfc;
+mod script;
+mod value;
+
+use json::Json;
+pub(crate) use json::number_to_string;
+use path::Loc;
+use regex::JsRegex;
+use rfc::Query;
+use std::cell::{Cell, RefCell};
+use std::collections::HashMap;
+use std::rc::Rc;
+use std::sync::LazyLock;
+use value::{JsValue, own_property};
+
+/// Nested `_trace` calls; a debug build spends about 8 KB of stack on each.
+const MAX_DEPTH: usize = 180;
+/// Steps a single evaluation may take; `..` after `..` grows exponentially with nesting.
+const MAX_STEPS: usize = 20_000_000;
+
+pub fn evaluate(text: &str, query: &str) -> Result {
+ let root = json::parse(text).map_err(|e| format!("Invalid JSON: {e}"))?;
+ if let Some(keys) = path::literal_keys(query) {
+ let found = keys.iter().try_fold(JsValue::from_doc(&root), |v, k| own_property(&v, k));
+ return Ok(json::stringify_results(found.as_slice()));
+ }
+ let engine = Engine::new(&root);
+ let values =
+ engine.run(query, &JsValue::from_doc(&root)).map_err(|e| format!("Invalid filter: {e}"))?;
+ Ok(json::stringify_results(&values))
+}
+
+#[derive(Clone, Debug)]
+enum PathElem {
+ Str(Rc),
+ Num(f64),
+}
+
+impl PathElem {
+ fn to_js(&self) -> JsValue<'static> {
+ match self {
+ PathElem::Str(s) => JsValue::string(s.to_string()),
+ PathElem::Num(n) => JsValue::Number(*n),
+ }
+ }
+
+ fn text(&self) -> String {
+ match self {
+ PathElem::Str(s) => s.to_string(),
+ PathElem::Num(n) => number_to_string(*n),
+ }
+ }
+}
+
+struct PathNode {
+ elem: PathElem,
+ prev: Path,
+}
+
+/// The steps from the root, newest first and shared, as jsonpath-plus copies its arrays.
+type Path = Option>;
+
+fn push(path: &Path, elem: PathElem) -> Path {
+ Some(Rc::new(PathNode { elem, prev: path.clone() }))
+}
+
+/// `JSONPath.toPathString`.
+fn path_string(path: &Path) -> String {
+ let mut elems = Vec::new();
+ let mut node = path.as_ref();
+ while let Some(n) = node {
+ elems.push(n.elem.text());
+ node = n.prev.as_ref();
+ }
+ elems.reverse();
+ let mut out = "$".to_string();
+ for elem in elems.iter().skip(1) {
+ let operator =
+ elem == "~" || elem == "^" || (elem.starts_with('@') && elem.ends_with("()"));
+ if operator {
+ continue;
+ }
+ if !elem.is_empty() && elem.bytes().all(|b| b.is_ascii_digit() || b == b'*') {
+ out.push_str(&format!("[{elem}]"));
+ } else {
+ out.push_str(&format!("['{elem}']"));
+ }
+ }
+ out
+}
+
+struct Ret<'a> {
+ path: Path,
+ value: JsValue<'a>,
+ /// The steps after a `^`, to trace again from the parent.
+ parent_selector: Option>,
+}
+
+enum Traced<'a> {
+ One(Ret<'a>),
+ Many(Vec