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>>), +} + +fn add<'a>(ret: &mut Vec>>, traced: Traced<'a>) { + match traced { + Traced::One(r) => ret.push(Some(r)), + Traced::Many(items) => ret.extend(items), + } +} + +enum FilterPlan { + /// jsonpath-plus spots `@.key[?(...)]` in a filter and keeps the items + /// whose `key` the inner filter selects anything from, ignoring the rest. + Nested { + key: String, + inner: Loc, + }, + Script(Rc