Move response filters into Rust (#755)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-10-15 06:22:23 -07:00
1 parent 19908b1902
commit 9b3715024d
42 files changed
+15538 -399

No files matched your search

Generated
+46
View File
@@ -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"
+2
View File
@@ -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" }
@@ -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='<a><b>x</b></a>', 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":"<b>x</b>"}"#))
.stdout(contains(r#"> x-bad: {"content":"","error":"Invalid filter: "#));
}
+1
View File
@@ -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 }
+5 -4
View File
@@ -1004,10 +1004,11 @@ async fn cmd_http_response_body<R: Runtime>(
.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 }),
}
}
+14
View File
@@ -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"] }
+423
View File
@@ -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<Json>),
Object(IndexMap<String, Json>),
}
pub fn parse(text: &str) -> Result<Json, String> {
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<u8> {
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<Json, String> {
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<Json, String> {
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<Json, String> {
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<Json, String> {
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<Json, String> {
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::<f64>().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<u16, String> {
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<String, String> {
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<String, Json>) -> IndexMap<String, Json> {
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<u32> {
if key.is_empty()
|| (key.len() > 1 && key.starts_with('0'))
|| !key.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
key.parse::<u32>().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<Item = &'v JsValue<'a>>,
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::<i32>().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}")
}
+798
View File
@@ -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<String, String> {
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<str>),
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<Rc<PathNode>>;
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<Rc<[Loc]>>,
}
enum Traced<'a> {
One(Ret<'a>),
Many(Vec<Option<Ret<'a>>>),
}
fn add<'a>(ret: &mut Vec<Option<Ret<'a>>>, 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<Script>),
}
struct Script {
expr: script::Expr,
code: String,
uses_path: bool,
}
#[allow(clippy::type_complexity)]
pub(crate) struct Engine<'a> {
root: &'a Json,
paths: RefCell<HashMap<String, Rc<[Loc]>>>,
scripts: RefCell<HashMap<String, Rc<Script>>>,
filters: RefCell<HashMap<String, Rc<FilterPlan>>>,
rfc_filters: RefCell<HashMap<String, Rc<rfc::Logical>>>,
/// RFC 9535 `match()` and `search()` patterns, `None` when invalid.
regexes: RefCell<HashMap<(String, bool), Option<Rc<JsRegex>>>>,
has_parent_selector: Cell<bool>,
depth: Cell<usize>,
steps: Cell<usize>,
}
static SLICE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
fancy_regex::Regex::new(r"^(-?[0-9]*):(-?[0-9]*):?([0-9]*)$")
.unwrap_or_else(|e| unreachable!("{e}"))
});
static NESTED: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
fancy_regex::Regex::new(r"@[^\n\r\x{2028}\x{2029}]?([^?]*)[\['](\??\([^\n\r\x{2028}\x{2029}]*?\))(?![^\n\r\x{2028}\x{2029}]\)\])[\]']")
.unwrap_or_else(|e| unreachable!("{e}"))
});
fn is_line_terminator(c: char) -> bool {
matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
}
impl<'a> Engine<'a> {
fn new(root: &'a Json) -> Self {
Engine {
root,
paths: RefCell::default(),
scripts: RefCell::default(),
filters: RefCell::default(),
rfc_filters: RefCell::default(),
regexes: RefCell::default(),
has_parent_selector: Cell::new(false),
depth: Cell::new(0),
steps: Cell::new(0),
}
}
/// `JSONPath.evaluate` from `start`, as values.
fn run(&self, query: &str, start: &JsValue<'a>) -> Result<Vec<JsValue<'a>>, String> {
let locs = self.path(query)?;
let locs: &[Loc] = match &locs[..] {
[Loc::Str(first), rest @ ..] if &**first == "$" && !rest.is_empty() => rest,
all => all,
};
let root_path = push(&None, PathElem::Str("$".into()));
let frame = Frame {
expr: locs,
val: start,
path: &root_path,
parent: &JsValue::Null,
parent_prop: &JsValue::Null,
literal_priority: false,
};
let traced = self.trace(&frame)?;
let rets = match traced {
Traced::One(r) => vec![Some(r)],
Traced::Many(items) => items,
};
Ok(rets
.into_iter()
.flatten()
.filter(|r| r.parent_selector.is_none())
.map(|r| r.value)
.collect())
}
pub(crate) fn query(
&self,
query: &Query,
current: &JsValue<'a>,
) -> Result<Vec<JsValue<'a>>, String> {
let start = if query.root { JsValue::from_doc(self.root) } else { current.clone() };
self.run(&query.path, &start)
}
pub(crate) fn i_regexp(&self, pattern: &str, whole: bool) -> Option<Rc<JsRegex>> {
self.regexes
.borrow_mut()
.entry((pattern.to_string(), whole))
.or_insert_with(|| JsRegex::i_regexp(pattern, whole).map(Rc::new))
.clone()
}
fn path(&self, query: &str) -> Result<Rc<[Loc]>, String> {
if let Some(locs) = self.paths.borrow().get(query) {
return Ok(locs.clone());
}
let locs: Rc<[Loc]> = path::parse(query)?.into();
for loc in locs.iter() {
self.check(loc)?;
}
self.paths.borrow_mut().insert(query.to_string(), locs.clone());
Ok(locs)
}
/// Compiles what a step will evaluate, so a malformed or unsupported
/// expression is an error even when no node reaches it.
fn check(&self, loc: &Loc) -> Result<(), String> {
match loc {
Loc::Str(s) | Loc::Part(s) => {
if s.starts_with("?(") {
self.filter(s).map(drop)
} else if s.starts_with('(') {
self.script(s).map(drop)
} else if s.contains(',') {
s.split(',').try_for_each(|part| self.check(&Loc::Part(part.into())))
} else {
Ok(())
}
}
Loc::Filter(text) => self.rfc_filter(text).map(drop),
Loc::Num(_) | Loc::Other(_) | Loc::ArrayKey(_) | Loc::Names(_) => Ok(()),
}
}
fn script(&self, code: &str) -> Result<Rc<Script>, String> {
if let Some(script) = self.scripts.borrow().get(code) {
return Ok(script.clone());
}
let expr = script::parse(&script::substitute(code)).map_err(|e| format!("{e}: {code}"))?;
let script =
Rc::new(Script { expr, code: code.to_string(), uses_path: code.contains("@path") });
self.scripts.borrow_mut().insert(code.to_string(), script.clone());
Ok(script)
}
fn filter(&self, loc: &str) -> Result<Rc<FilterPlan>, String> {
if let Some(plan) = self.filters.borrow().get(loc) {
return Ok(plan.clone());
}
let code = match loc.strip_prefix("?(").and_then(|s| s.strip_suffix(')')) {
Some(inner) if !loc.contains(is_line_terminator) => inner,
_ => loc,
};
let nested = NESTED.captures(code).map_err(|e| e.to_string())?;
let plan = match nested {
Some(c) => {
let inner = Loc::Str(c[2].into());
self.check(&inner)?;
FilterPlan::Nested { key: c[1].to_string(), inner }
}
None => FilterPlan::Script(self.script(code)?),
};
let plan = Rc::new(plan);
self.filters.borrow_mut().insert(loc.to_string(), plan.clone());
Ok(plan)
}
fn rfc_filter(&self, text: &str) -> Result<Rc<rfc::Logical>, String> {
if let Some(filter) = self.rfc_filters.borrow().get(text) {
return Ok(filter.clone());
}
let filter = Rc::new(rfc::parse(text).map_err(|e| format!("{e}: ?{text}"))?);
self.rfc_filters.borrow_mut().insert(text.to_string(), filter.clone());
Ok(filter)
}
fn run_script(
&self,
script: &Script,
value: &JsValue<'a>,
property: JsValue<'a>,
path: impl FnOnce() -> String,
parent: &JsValue<'a>,
parent_property: &JsValue<'a>,
) -> Result<JsValue<'a>, String> {
let scope = script::Scope {
value: value.clone(),
property,
parent: parent.clone(),
parent_property: parent_property.clone(),
path: script.uses_path.then(path),
};
script::eval(&script.expr, &scope, self).map_err(|e| format!("{e}: {}", script.code))
}
fn trace(&self, f: &Frame<'_, 'a>) -> Result<Traced<'a>, String> {
let depth = self.depth.get() + 1;
if depth > MAX_DEPTH {
return Err("the path goes too deep".to_string());
}
self.steps.set(self.steps.get() + 1);
if self.steps.get() > MAX_STEPS {
return Err("the path takes too many steps to evaluate".to_string());
}
self.depth.set(depth);
let traced = self.trace_inner(f);
self.depth.set(depth - 1);
traced
}
/// Traces the rest of the path from a child of the frame's value.
fn child(
&self,
f: &Frame<'_, 'a>,
child: &JsValue<'a>,
elem: PathElem,
literal_priority: bool,
ret: &mut Vec<Option<Ret<'a>>>,
) -> Result<(), String> {
let path = push(f.path, elem.clone());
let parent_prop = elem.to_js();
let next = Frame {
expr: f.rest(),
val: child,
path: &path,
parent: f.val,
parent_prop: &parent_prop,
literal_priority,
};
add(ret, self.trace(&next)?);
Ok(())
}
/// Traces other steps from the frame's own value.
fn again(
&self,
f: &Frame<'_, 'a>,
expr: &[Loc],
ret: &mut Vec<Option<Ret<'a>>>,
) -> Result<(), String> {
add(ret, self.trace(&Frame { expr, literal_priority: false, ..*f })?);
Ok(())
}
fn with_first(first: Loc, rest: &[Loc]) -> Vec<Loc> {
std::iter::once(first).chain(rest.iter().cloned()).collect()
}
fn trace_inner(&self, f: &Frame<'_, 'a>) -> Result<Traced<'a>, String> {
let Some(loc) = f.expr.first() else {
return Ok(Traced::One(Ret {
path: f.path.clone(),
value: f.val.clone(),
parent_selector: None,
}));
};
let mut ret = Vec::new();
match loc {
Loc::Names(names) => {
let child_priority = names.len() > 1 || !f.literal_priority;
for name in names.iter() {
if let Some(child) = f.own(name) {
self.child(
f,
&child,
PathElem::Str(name.as_str().into()),
child_priority,
&mut ret,
)?;
}
}
}
Loc::Filter(text) => {
let filter = self.rfc_filter(text)?;
for (m, child) in walk(f.val) {
if filter.test(&child, self)? {
self.child(f, &child, m, false, &mut ret)?;
}
}
}
Loc::Num(_) | Loc::Other(_) | Loc::ArrayKey(_) => {
let elem = match loc {
Loc::Num(n) => PathElem::Num(*n),
_ => PathElem::Str(loc_text(loc).into()),
};
match f.own(&elem.text()) {
Some(child) => self.child(f, &child, elem, false, &mut ret)?,
None if matches!(loc, Loc::ArrayKey(_)) => {}
None => {
return Err(format!(
"{} is not a property name the path can follow",
elem.text()
));
}
}
}
Loc::Str(s) | Loc::Part(s) => {
if let Some(early) = self.trace_str(f, s, matches!(loc, Loc::Str(_)), &mut ret)? {
return Ok(early);
}
}
}
if self.has_parent_selector.get() {
self.resolve_parent_selectors(f, &mut ret)?;
}
Ok(Traced::Many(ret))
}
/// A `^` below this frame resolves here, by tracing what follows it from this frame's value.
fn resolve_parent_selectors(
&self,
f: &Frame<'_, 'a>,
ret: &mut Vec<Option<Ret<'a>>>,
) -> Result<(), String> {
let mut t = 0;
while t < ret.len() {
let selector = ret[t]
.as_ref()
.and_then(|r| r.parent_selector.clone().map(|s| (s, r.path.clone())));
if let Some((rest, path)) = selector {
let frame = Frame { expr: &rest, path: &path, literal_priority: false, ..*f };
match self.trace(&frame)? {
Traced::One(r) => ret[t] = Some(r),
Traced::Many(items) => {
let mut items = items.into_iter();
ret[t] = items.next().flatten();
for item in items {
t += 1;
ret.insert(t, item);
}
}
}
}
t += 1;
}
Ok(())
}
/// `_trace` for a text step, in jsonpath-plus's order of tests. Returns
/// the result itself for the steps that end the trace (`^`, `~`, a type).
fn trace_str(
&self,
f: &Frame<'_, 'a>,
s: &str,
rfc_additions: bool,
ret: &mut Vec<Option<Ret<'a>>>,
) -> Result<Option<Traced<'a>>, String> {
let named = |key: &str| PathElem::Str(key.into());
if let Some(child) = f.own(s).filter(|_| f.literal_priority) {
self.child(f, &child, named(s), false, ret)?;
} else if s == "*" {
for (m, child) in walk(f.val) {
self.child(f, &child, m, true, ret)?;
}
} else if s == ".." {
self.again(f, f.rest(), ret)?;
for (m, child) in walk(f.val) {
if matches!(child, JsValue::Doc(_) | JsValue::Null) {
let path = push(f.path, m.clone());
let parent_prop = m.to_js();
let next = Frame {
val: &child,
path: &path,
parent: f.val,
parent_prop: &parent_prop,
literal_priority: false,
..*f
};
add(ret, self.trace(&next)?);
}
}
} else if s == "^" {
self.has_parent_selector.set(true);
let parent_path = f.path.as_ref().and_then(|n| n.prev.clone());
return Ok(Some(Traced::One(Ret {
path: parent_path,
value: JsValue::Undefined,
parent_selector: Some(f.rest().into()),
})));
} else if s == "~" {
return Ok(Some(Traced::One(Ret {
path: push(f.path, named("~")),
value: f.parent_prop.clone(),
parent_selector: None,
})));
} else if s == "$" {
let frame = Frame {
expr: f.rest(),
parent: &JsValue::Null,
parent_prop: &JsValue::Null,
literal_priority: false,
..*f
};
add(ret, self.trace(&frame)?);
} else if SLICE.is_match(s).map_err(|e| e.to_string())? {
if let JsValue::Doc(Json::Array(items)) = f.val {
self.trace_indexes(f, slice_indexes(s, items.len()), ret)?;
}
} else if s.starts_with("?(") {
self.trace_filter(f, s, ret)?;
} else if s.starts_with('(') {
let script = self.script(s)?;
let property = f.path.as_ref().map_or(JsValue::Undefined, |n| n.elem.to_js());
let result = self.run_script(
&script,
f.val,
property,
|| path_string(f.path),
f.parent,
f.parent_prop,
)?;
let next = match result {
JsValue::Undefined => Loc::Str("".into()),
JsValue::String(s) => Loc::Part(s.as_ref().into()),
JsValue::Number(n) => Loc::Num(n),
array @ (JsValue::Doc(Json::Array(_)) | JsValue::Array(_)) => {
Loc::ArrayKey(array.to_js_string().into())
}
other => Loc::Other(other.to_js_string().into()),
};
self.again(f, &Self::with_first(next, f.rest()), ret)?;
} else if s.starts_with('@') {
if value_type(s)?.matches(f.val) {
return Ok(Some(Traced::One(Ret {
path: f.path.clone(),
value: f.val.clone(),
parent_selector: None,
})));
}
} else if let Some((key, child)) =
s.strip_prefix('`').and_then(|key| f.own(key).map(|child| (key, child)))
{
self.child(f, &child, named(key), true, ret)?;
} else if s.contains(',') {
for part in s.split(',') {
self.again(f, &Self::with_first(Loc::Part(part.into()), f.rest()), ret)?;
}
} else if let Some(child) = f.own(s).filter(|_| !f.literal_priority) {
self.child(f, &child, named(s), true, ret)?;
} else if rfc_additions && let JsValue::Doc(Json::Array(items)) = f.val {
if let Some(index) = negative_index(s, items.len()) {
self.trace_indexes(f, vec![index], ret)?;
} else if let Some(indexes) = reverse_slice_indexes(s, items.len()) {
self.trace_indexes(f, indexes, ret)?;
}
}
Ok(None)
}
fn trace_filter(
&self,
f: &Frame<'_, 'a>,
loc: &str,
ret: &mut Vec<Option<Ret<'a>>>,
) -> Result<(), String> {
match &*self.filter(loc)? {
FilterPlan::Nested { key, inner } => {
for (m, child) in walk(f.val) {
let nested = if key.is_empty() {
child.clone()
} else if matches!(child, JsValue::Null) {
return Err(format!("Cannot read properties of null (reading '{key}')"));
} else {
own_property(&child, key).unwrap_or(JsValue::Undefined)
};
let frame = Frame {
expr: std::slice::from_ref(inner),
val: &nested,
literal_priority: false,
..*f
};
let found = match self.trace(&frame)? {
Traced::One(_) => true,
Traced::Many(items) => !items.is_empty(),
};
if found {
self.child(f, &child, m, false, ret)?;
}
}
}
FilterPlan::Script(script) => {
for (m, child) in walk(f.val) {
let at_path = || path_string(&push(f.path, m.clone()));
if self
.run_script(script, &child, m.to_js(), at_path, f.parent, f.parent_prop)?
.truthy()
{
self.child(f, &child, m, false, ret)?;
}
}
}
}
Ok(())
}
fn trace_indexes(
&self,
f: &Frame<'_, 'a>,
indexes: Vec<f64>,
ret: &mut Vec<Option<Ret<'a>>>,
) -> Result<(), String> {
for i in indexes {
self.again(f, &Self::with_first(Loc::Num(i), f.rest()), ret)?;
}
Ok(())
}
}
/// One `_trace` call's arguments.
#[derive(Clone, Copy)]
struct Frame<'f, 'a> {
expr: &'f [Loc],
val: &'f JsValue<'a>,
path: &'f Path,
parent: &'f JsValue<'a>,
parent_prop: &'f JsValue<'a>,
literal_priority: bool,
}
impl<'a> Frame<'_, 'a> {
fn rest(&self) -> &[Loc] {
self.expr.get(1..).unwrap_or_default()
}
/// `val && Object.hasOwn(val, key)`, and the property.
fn own(&self, key: &str) -> Option<JsValue<'a>> {
self.val.truthy().then(|| own_property(self.val, key)).flatten()
}
}
fn loc_text(loc: &Loc) -> String {
match loc {
Loc::Str(s) | Loc::Part(s) | Loc::Other(s) | Loc::ArrayKey(s) | Loc::Filter(s) => {
s.to_string()
}
Loc::Num(n) => number_to_string(*n),
Loc::Names(names) => names.join(","),
}
}
/// `_walk`: array indexes, or object keys in `JSON.parse` order.
fn walk<'a>(val: &JsValue<'a>) -> Vec<(PathElem, JsValue<'a>)> {
match val {
JsValue::Doc(Json::Array(items)) => items
.iter()
.enumerate()
.map(|(i, item)| (PathElem::Num(i as f64), JsValue::from_doc(item)))
.collect(),
JsValue::Doc(Json::Object(map)) => map
.iter()
.map(|(k, v)| (PathElem::Str(k.as_str().into()), JsValue::from_doc(v)))
.collect(),
_ => Vec::new(),
}
}
/// `_slice`: Python-style `start:end:step` with jsonpath-plus's defaults.
fn slice_indexes(loc: &str, len: usize) -> Vec<f64> {
let len = len as f64;
let parts: Vec<&str> = loc.split(':').collect();
let number = |s: &str| value::string_to_number(s);
let or_default = |n: f64, default: f64| if n == 0.0 || n.is_nan() { default } else { n };
let step = parts.get(2).filter(|s| !s.is_empty()).map_or(1.0, |s| or_default(number(s), 1.0));
let start = parts.first().filter(|s| !s.is_empty()).map_or(0.0, |s| or_default(number(s), 0.0));
let end = parts.get(1).filter(|s| !s.is_empty()).map_or(len, |s| number(s));
let mut indexes = Vec::new();
if end.is_nan() {
return indexes;
}
let start = if start < 0.0 { (start + len).max(0.0) } else { start.min(len) };
let end = if end < 0.0 { (end + len).max(0.0) } else { end.min(len) };
let mut i = start;
while i < end {
indexes.push(i);
i += step;
}
indexes
}
fn parse_int(s: &str) -> Option<i64> {
let digits = s.strip_prefix('-').unwrap_or(s);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(s.parse::<i64>().unwrap_or(if s.starts_with('-') { i64::MIN } else { i64::MAX }))
}
/// RFC 9535 `[-n]`, which jsonpath-plus looks up as the key "-n".
fn negative_index(loc: &str, len: usize) -> Option<f64> {
let n = parse_int(loc).filter(|n| *n < 0 && !loc.starts_with("-0"))?;
let index = len as i64 + n;
(index >= 0).then_some(index as f64)
}
/// An RFC 9535 slice with a negative step, which jsonpath-plus does not read as a slice.
fn reverse_slice_indexes(loc: &str, len: usize) -> Option<Vec<f64>> {
let parts: Vec<&str> = loc.split(':').collect();
let [start, end, step] = parts[..] else {
return None;
};
let step = parse_int(step).filter(|s| *s < 0)?;
let bound = |s: &str| if s.is_empty() { Some(None) } else { parse_int(s).map(Some) };
let (start, end) = (bound(start)?, bound(end)?);
let len = len as i64;
let normalize = |i: i64| if i >= 0 { i } else { len.saturating_add(i) };
let upper = normalize(start.unwrap_or(len - 1)).clamp(-1, len - 1);
let lower = end.map_or(-1, |e| normalize(e).clamp(-1, len - 1));
let mut indexes = Vec::new();
let mut i = upper;
while lower < i {
indexes.push(i as f64);
i = i.saturating_add(step);
}
Some(indexes)
}
#[derive(Clone, Copy)]
enum ValueType {
Scalar,
Boolean,
String,
Undefined,
Function,
Integer,
Number,
NonFinite,
Object,
Array,
Null,
}
/// The `@string()`-style step, by jsonpath-plus's `loc.slice(1, -2)`.
fn value_type(loc: &str) -> Result<ValueType, String> {
let chars: Vec<char> = loc.chars().collect();
let name: String =
chars.get(1..chars.len().saturating_sub(2)).map(|c| c.iter().collect()).unwrap_or_default();
Ok(match name.as_str() {
"scalar" => ValueType::Scalar,
"boolean" => ValueType::Boolean,
"string" => ValueType::String,
"undefined" => ValueType::Undefined,
"function" => ValueType::Function,
"integer" => ValueType::Integer,
"number" => ValueType::Number,
"nonFinite" => ValueType::NonFinite,
"object" => ValueType::Object,
"array" => ValueType::Array,
"null" => ValueType::Null,
"other" => {
return Err(
"@other() needs an otherTypeCallback, which filters cannot supply".to_string()
);
}
name => return Err(format!("Unknown value type {name}")),
})
}
impl ValueType {
fn matches(self, val: &JsValue<'_>) -> bool {
match self {
ValueType::Scalar => !val.truthy() || !val.is_object(),
ValueType::Boolean => matches!(val, JsValue::Bool(_)),
ValueType::String => matches!(val, JsValue::String(_)),
ValueType::Undefined => matches!(val, JsValue::Undefined),
ValueType::Function => matches!(val, JsValue::Function(_)),
ValueType::Integer => {
matches!(val, JsValue::Number(n) if n.is_finite() && n.fract() == 0.0)
}
ValueType::Number => matches!(val, JsValue::Number(n) if n.is_finite()),
ValueType::NonFinite => matches!(val, JsValue::Number(n) if !n.is_finite()),
ValueType::Object => val.truthy() && val.type_of() == "object",
ValueType::Array => matches!(val, JsValue::Doc(Json::Array(_)) | JsValue::Array(_)),
ValueType::Null => matches!(val, JsValue::Null),
}
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Path strings into the steps the engine traces: jsonpath-plus's
//! `toPathArray`, after lifting out what this engine reads differently.
use super::json::{self, Json};
use super::rfc::{closing_bracket, quoted_string};
use fancy_regex::{Captures, Regex};
use std::rc::Rc;
use std::sync::LazyLock;
/// One step of a path. jsonpath-plus decides what a step means while tracing,
/// so most steps stay text until then.
#[derive(Debug, Clone)]
pub enum Loc {
Str(Rc<str>),
/// A part of a `,` union or a script's result, which take none of the RFC 9535 additions.
Part(Rc<str>),
Num(f64),
/// A script result that is neither a string nor a number, by its string form.
Other(Rc<str>),
/// A script's array result, which jsonpath-plus follows only if its string form is a key.
ArrayKey(Rc<str>),
/// Quoted names: always literal keys.
Names(Rc<[String]>),
/// An RFC 9535 filter written without parentheses.
Filter(Rc<str>),
}
const PLACEHOLDER_OPEN: char = '\u{E000}';
const PLACEHOLDER_CLOSE: char = '\u{E001}';
static PROPERTY_TYPE: LazyLock<Regex> = LazyLock::new(|| regex(r"@[A-Za-z0-9_$\-]+\(\)"));
static SCRIPT: LazyLock<Regex> = LazyLock::new(|| {
regex(r"[\['](\??\([^\n\r\x{2028}\x{2029}]*?\))[\]'](?![^\n\r\x{2028}\x{2029}]\])")
});
static QUOTED: LazyLock<Regex> = LazyLock::new(|| regex(r#"\[['"]([^'\]]*)['"]\]"#));
static BOUNDARY: LazyLock<Regex> = LazyLock::new(|| regex(r#"['"]?\.['"]?(?![^\[]*\])|\[['"]?"#));
static PARENT: LazyLock<Regex> = LazyLock::new(|| regex(r"(?:;)?(\^+)(?:;)?"));
static DESCENDANT: LazyLock<Regex> = LazyLock::new(|| regex(r";;;|;;"));
static TRAILING: LazyLock<Regex> = LazyLock::new(|| regex(r";$|'?\]|'$"));
static SUBX: LazyLock<Regex> = LazyLock::new(|| regex(r"#([0-9]+)"));
fn regex(pattern: &str) -> Regex {
Regex::new(pattern).unwrap_or_else(|e| unreachable!("{pattern}: {e}"))
}
fn replace(
re: &Regex,
text: &str,
rep: impl FnMut(&Captures<'_>) -> String,
) -> Result<String, String> {
re.try_replacen(text, 0, rep)
.map(|s| s.into_owned())
.map_err(|e| format!("path is too complex: {e}"))
}
pub fn parse(path: &str) -> Result<Vec<Loc>, String> {
let (text, lifted) = if path.contains([PLACEHOLDER_OPEN, PLACEHOLDER_CLOSE]) {
(path.to_string(), Vec::new())
} else {
lift(path)
};
let mut subx: Vec<String> = Vec::new();
let normalized = replace(&PROPERTY_TYPE, &text, |c| format!(";{};", &c[0]))?;
let normalized = replace(&SCRIPT, &normalized, |c| {
subx.push(c[1].to_string());
format!("[#{}]", subx.len() - 1)
})?;
let normalized = replace(&QUOTED, &normalized, |c| {
format!("['{}']", c[1].replace('.', "%@%").replace('~', "%%@@%%"))
})?;
let normalized = normalized.replace('~', ";~;");
let normalized = replace(&BOUNDARY, &normalized, |_| ";".to_string())?;
let normalized = normalized.replace("%@%", ".").replace("%%@@%%", "~");
let normalized = replace(&PARENT, &normalized, |c| {
let ups: Vec<&str> = c[1].split("").filter(|s| !s.is_empty()).collect();
format!(";{};", ups.join(";"))
})?;
let normalized = replace(&DESCENDANT, &normalized, |_| ";..;".to_string())?;
let normalized = replace(&TRAILING, &normalized, |_| String::new())?;
normalized
.split(';')
.map(|part| {
if let Some(index) = placeholder_index(part) {
return Ok(lifted.get(index).cloned().unwrap_or_else(|| Loc::Str(part.into())));
}
let subx_index =
SUBX.captures(part).map_err(|e| e.to_string())?.map(|c| c[1].parse::<usize>().ok());
Ok(match subx_index {
None => Loc::Str(part.into()),
Some(index) => match index.and_then(|i| subx.get(i)) {
Some(script) => Loc::Str(script.as_str().into()),
None => Loc::Other("undefined".into()),
},
})
})
.collect()
}
/// The old plugin walked complete literal paths containing `["` by own
/// property instead of handing them to jsonpath-plus (so `$["s"].length` is
/// `0` for an empty string). These are their keys.
pub fn literal_keys(path: &str) -> Option<Vec<String>> {
if !path.contains("[\"") {
return None;
}
let mut rest = path.trim().strip_prefix('$')?;
let mut keys = Vec::new();
while !rest.is_empty() {
if let Some(after) = rest.strip_prefix('.') {
let len = after
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
.unwrap_or(after.len());
let name = &after[..len];
if name.is_empty() || name == "$" || name.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
keys.push(name.to_string());
rest = &after[len..];
} else if let Some(after) = rest.strip_prefix("[\"") {
let mut escaped = false;
let end = after.char_indices().find_map(|(i, c)| {
let closes = c == '"' && !escaped;
escaped = c == '\\' && !escaped;
closes.then_some(i)
})?;
let Json::String(key) = json::parse(&format!("\"{}", &after[..=end])).ok()? else {
return None;
};
keys.push(key);
rest = after[end + 1..].strip_prefix(']')?;
} else if let Some(after) = rest.strip_prefix('[') {
let len = after.find(|c: char| !c.is_ascii_digit()).unwrap_or(after.len());
let digits = &after[..len];
let index = digits.parse::<u64>().ok().filter(|i| *i <= 9_007_199_254_740_991)?;
if digits.len() > 1 && digits.starts_with('0') {
return None;
}
keys.push(index.to_string());
rest = after[len..].strip_prefix(']')?;
} else {
return None;
}
}
Some(keys)
}
fn placeholder_index(part: &str) -> Option<usize> {
part.strip_prefix(PLACEHOLDER_OPEN)?.strip_suffix(PLACEHOLDER_CLOSE)?.parse().ok()
}
/// Lifts out quoted names (`["a.b"]`, `['a', 'b']`) and RFC 9535 filters
/// without parentheses (`[?@.a > 1]`), leaving placeholders jsonpath-plus's
/// normalization passes through as single steps.
fn lift(path: &str) -> (String, Vec<Loc>) {
let chars: Vec<char> = path.chars().collect();
let mut out = String::new();
let mut lifted = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '[' {
let next = chars.get(i + 1).copied();
let after = chars.get(i + 2).copied();
let replacement = match next {
Some('?') if after != Some('(') => closing_bracket(&chars, i).map(|close| {
let text: String = chars[i + 2..close - 1].iter().collect();
(Loc::Filter(text.into()), close)
}),
Some('?' | '(') => {
if let Some(close) = closing_bracket(&chars, i) {
out.extend(&chars[i..close]);
i = close;
continue;
}
None
}
_ => quoted_names(&chars, i + 1)
.map(|(names, close)| (Loc::Names(names.into()), close)),
};
if let Some((loc, close)) = replacement {
out.push_str(&format!("[{PLACEHOLDER_OPEN}{}{PLACEHOLDER_CLOSE}]", lifted.len()));
lifted.push(loc);
i = close;
continue;
}
}
out.push(chars[i]);
i += 1;
}
(out, lifted)
}
/// `"a"` or `'a', "b"` up to the closing `]`, and the index after it.
fn quoted_names(chars: &[char], mut i: usize) -> Option<(Vec<String>, usize)> {
let blank = |c: &char| matches!(c, ' ' | '\t' | '\n' | '\r');
let mut names = Vec::new();
loop {
while chars.get(i).is_some_and(blank) {
i += 1;
}
let (name, end) = quoted_string(chars, i)?;
names.push(name);
i = end;
while chars.get(i).is_some_and(blank) {
i += 1;
}
match chars.get(i) {
Some(']') => return Some((names, i + 1)),
Some(',') => i += 1,
_ => return None,
}
}
}
+367
View File
@@ -0,0 +1,367 @@
//! JavaScript regular expressions on `fancy-regex`.
use super::value::{JsArray, JsValue, MatchInfo};
use fancy_regex::{Captures, Regex, RegexBuilder};
use std::cell::Cell;
use std::fmt;
use std::rc::Rc;
const JS_WHITESPACE: &str =
r"\t\n\x0B\x0C\r \u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF";
/// JavaScript's `\b` and `\B` only count ASCII word characters.
const WORD_BOUNDARY: &str =
r"(?:(?<=[0-9A-Za-z_])(?![0-9A-Za-z_])|(?<![0-9A-Za-z_])(?=[0-9A-Za-z_]))";
const NOT_WORD_BOUNDARY: &str =
r"(?:(?<=[0-9A-Za-z_])(?=[0-9A-Za-z_])|(?<![0-9A-Za-z_])(?![0-9A-Za-z_]))";
const NOT_LINE_TERMINATOR: &str = r"[^\n\r\u2028\u2029]";
pub struct JsRegex {
pub source: String,
pub flags: String,
regex: Regex,
global: bool,
/// In UTF-16 code units, as JavaScript counts it.
pub last_index: Cell<usize>,
}
impl fmt::Debug for JsRegex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "/{}/{}", self.source, self.flags)
}
}
impl JsRegex {
pub fn new(source: &str, flags: &str) -> Result<Self, String> {
let mut seen = String::new();
for flag in flags.chars() {
if seen.contains(flag) || !"dgimsuvy".contains(flag) {
return Err(format!("Invalid flags supplied to RegExp constructor '{flags}'"));
}
if flag == 'v' || flag == 'y' {
return Err(format!("the regular expression flag {flag} is not supported"));
}
seen.push(flag);
}
let dot_all = flags.contains('s');
let translated = translate(source, dot_all)?;
let regex = RegexBuilder::new(&translated)
.case_insensitive(flags.contains('i'))
.multi_line(flags.contains('m'))
.build()
.map_err(|e| format!("Invalid regular expression: /{source}/{flags}: {e}"))?;
Ok(JsRegex {
source: if source.is_empty() { "(?:)".to_string() } else { source.to_string() },
flags: flags.to_string(),
regex,
global: flags.contains('g'),
last_index: Cell::new(0),
})
}
/// An RFC 9535 `match()` or `search()` pattern; `None` when it is not one this engine reads.
pub fn i_regexp(pattern: &str, whole: bool) -> Option<Self> {
let source = if whole { format!("^(?:{pattern})$") } else { pattern.to_string() };
JsRegex::new(&source, "u").ok()
}
pub fn is_match(&self, text: &str) -> Result<bool, String> {
self.regex.is_match(text).map_err(runtime_error)
}
pub fn test(&self, text: &str) -> Result<bool, String> {
if !self.global {
return self.is_match(text);
}
let Some(start) = utf16_to_byte(text, self.last_index.get()) else {
self.last_index.set(0);
return Ok(false);
};
match self.regex.find_from_pos(text, start).map_err(runtime_error)? {
Some(m) => {
self.last_index.set(byte_to_utf16(text, m.end()));
Ok(true)
}
None => {
self.last_index.set(0);
Ok(false)
}
}
}
pub fn match_string<'a>(&self, text: &str) -> Result<JsValue<'a>, String> {
if self.global {
self.last_index.set(0);
let mut matches = Vec::new();
let mut pos = 0;
while pos <= text.len() {
let Some(m) = self.regex.find_from_pos(text, pos).map_err(runtime_error)? else {
break;
};
matches.push(JsValue::string(m.as_str()));
pos = if m.end() == m.start() { next_boundary(text, m.end()) } else { m.end() };
}
return Ok(if matches.is_empty() { JsValue::Null } else { JsValue::array(matches) });
}
let Some(captures) = self.regex.captures(text).map_err(runtime_error)? else {
return Ok(JsValue::Null);
};
let index = captures.get(0).map_or(0, |m| byte_to_utf16(text, m.start()));
Ok(JsValue::Array(Rc::new(JsArray {
items: capture_values(&captures, 0),
match_info: Some(MatchInfo { index: index as f64, input: text.to_string() }),
})))
}
/// `String.prototype.split` with a regular expression separator, captures included.
pub fn split<'a>(&self, text: &str, limit: usize) -> Result<Vec<JsValue<'a>>, String> {
let mut parts = Vec::new();
if limit == 0 {
return Ok(parts);
}
if text.is_empty() {
if self.regex.find(text).map_err(runtime_error)?.is_none() {
parts.push(JsValue::string(""));
}
return Ok(parts);
}
let (mut p, mut q) = (0, 0);
while q < text.len() {
let Some(captures) = self.regex.captures_from_pos(text, q).map_err(runtime_error)?
else {
break;
};
let Some(whole) = captures.get(0) else { break };
q = whole.start();
if q >= text.len() {
break;
}
let e = whole.end().min(text.len());
if e == p {
q = next_boundary(text, q);
continue;
}
parts.push(JsValue::string(&text[p..q]));
if parts.len() == limit {
return Ok(parts);
}
p = e;
for capture in capture_values(&captures, 1) {
parts.push(capture);
if parts.len() == limit {
return Ok(parts);
}
}
q = p;
}
parts.push(JsValue::string(&text[p..]));
Ok(parts)
}
}
fn capture_values<'a>(captures: &Captures<'_>, from: usize) -> Vec<JsValue<'a>> {
(from..captures.len())
.map(|i| captures.get(i).map_or(JsValue::Undefined, |m| JsValue::string(m.as_str())))
.collect()
}
fn runtime_error(e: fancy_regex::Error) -> String {
format!("regular expression failed: {e}")
}
fn next_boundary(text: &str, pos: usize) -> usize {
text[pos..].chars().next().map_or(pos + 1, |c| pos + c.len_utf8())
}
fn utf16_to_byte(text: &str, index: usize) -> Option<usize> {
let mut units = 0;
for (byte, c) in text.char_indices() {
if units >= index {
return Some(byte);
}
units += c.len_utf16();
}
(units >= index).then_some(text.len())
}
fn byte_to_utf16(text: &str, byte: usize) -> usize {
text[..byte].encode_utf16().count()
}
/// Rewrites a JavaScript pattern into `fancy-regex` syntax with JavaScript's
/// meaning for `.`, `\d`, `\w`, `\s`, `\b` and the characters JavaScript
/// takes literally (`]`, `{` and `}` outside a quantifier, `[` inside a class).
fn translate(source: &str, dot_all: bool) -> Result<String, String> {
let chars: Vec<char> = source.chars().collect();
let mut out = String::new();
let mut in_class = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
i += 1;
match c {
'\\' => {
let Some(&e) = chars.get(i) else {
return Err("Invalid regular expression: \\ at end of pattern".to_string());
};
i += 1;
match e {
'd' => out.push_str(if in_class { "0-9" } else { "[0-9]" }),
'D' => out.push_str("[^0-9]"),
'w' => out.push_str(if in_class { "0-9A-Za-z_" } else { "[0-9A-Za-z_]" }),
'W' => out.push_str("[^0-9A-Za-z_]"),
's' if in_class => out.push_str(JS_WHITESPACE),
's' => out.push_str(&format!("[{JS_WHITESPACE}]")),
'S' => out.push_str(&format!("[^{JS_WHITESPACE}]")),
'b' if in_class => out.push_str(r"\x08"),
'b' => out.push_str(WORD_BOUNDARY),
'B' => out.push_str(NOT_WORD_BOUNDARY),
'n' => out.push_str(r"\n"),
'r' => out.push_str(r"\r"),
't' => out.push_str(r"\t"),
'f' => out.push_str(r"\x0C"),
'v' => out.push_str(r"\x0B"),
'0' if !chars.get(i).is_some_and(char::is_ascii_digit) => out.push_str(r"\x00"),
'1'..='9' if !in_class => {
out.push('\\');
out.push(e);
while let Some(d) = chars.get(i).filter(|d| d.is_ascii_digit()) {
out.push(*d);
i += 1;
}
}
'x' if hex_run(&chars, i, 2) => {
out.push_str(&format!(
r"\x{{{}}}",
chars[i..i + 2].iter().collect::<String>()
));
i += 2;
}
'u' if chars.get(i) == Some(&'{') => {
let end = chars[i..].iter().position(|c| *c == '}').map(|p| p + i);
let Some(end) = end else {
return Err(
"Invalid regular expression: invalid Unicode escape".to_string()
);
};
out.push_str(&format!(
r"\x{{{}}}",
chars[i + 1..end].iter().collect::<String>()
));
i = end + 1;
}
'u' if hex_run(&chars, i, 4) => {
let high =
u32::from_str_radix(&chars[i..i + 4].iter().collect::<String>(), 16)
.unwrap_or(0);
i += 4;
let mut code = high;
if (0xD800..0xDC00).contains(&high)
&& chars.get(i) == Some(&'\\')
&& chars.get(i + 1) == Some(&'u')
&& hex_run(&chars, i + 2, 4)
{
let low = u32::from_str_radix(
&chars[i + 2..i + 6].iter().collect::<String>(),
16,
)
.unwrap_or(0);
if (0xDC00..0xE000).contains(&low) {
code = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
i += 6;
}
}
out.push_str(&format!(r"\x{{{code:X}}}"));
}
'c' if chars.get(i).is_some_and(char::is_ascii_alphabetic) => {
out.push_str(&format!(r"\x{{{:X}}}", (chars[i] as u32) % 32));
i += 1;
}
'k' if chars.get(i) == Some(&'<') => out.push_str(r"\k"),
'p' | 'P' if chars.get(i) == Some(&'{') => {
let end = chars[i..]
.iter()
.position(|c| *c == '}')
.map_or(chars.len() - 1, |p| p + i);
out.push('\\');
out.push(e);
out.extend(&chars[i..=end]);
i = end + 1;
}
e => push_literal(&mut out, e),
}
}
'[' if in_class => out.push_str(r"\["),
'[' => {
let negated = chars.get(i) == Some(&'^');
let body = if negated { i + 1 } else { i };
if chars.get(body) == Some(&']') {
out.push_str(if negated { r"[\s\S]" } else { r"[^\s\S]" });
i = body + 1;
} else {
in_class = true;
out.push('[');
if negated {
out.push('^');
i += 1;
}
}
}
']' if in_class => {
in_class = false;
out.push(']');
}
'&' | '~' if in_class => {
out.push('\\');
out.push(c);
}
']' | '}' => push_literal(&mut out, c),
'{' if !in_class => {
let quantifier = quantifier_len(&chars[i..]);
if quantifier > 0 && !out.is_empty() {
out.push('{');
out.extend(&chars[i..i + quantifier]);
i += quantifier;
} else {
push_literal(&mut out, '{');
}
}
'.' if !in_class && !dot_all => out.push_str(NOT_LINE_TERMINATOR),
'.' if !in_class => out.push_str(r"[\s\S]"),
'(' if !in_class && chars.get(i) == Some(&'?') && chars.get(i + 1) == Some(&'<') => {
if matches!(chars.get(i + 2), Some('=' | '!')) {
out.push_str("(?<");
} else {
out.push_str("(?P<");
}
i += 2;
}
c => out.push(c),
}
}
Ok(out)
}
fn push_literal(out: &mut String, c: char) {
if c.is_ascii_punctuation() || c == ' ' {
out.push('\\');
}
out.push(c);
}
fn hex_run(chars: &[char], from: usize, len: usize) -> bool {
chars.get(from..from + len).is_some_and(|run| run.iter().all(char::is_ascii_hexdigit))
}
/// The length of `n}`, `n,}` or `n,m}` after a `{`, or 0 when JavaScript reads the brace literally.
fn quantifier_len(rest: &[char]) -> usize {
let digits = |from: usize| rest[from..].iter().take_while(|c| c.is_ascii_digit()).count();
let mut i = digits(0);
if i == 0 {
return 0;
}
if rest.get(i) == Some(&',') {
i += 1;
i += digits(i);
}
if rest.get(i) == Some(&'}') { i + 1 } else { 0 }
}
+711
View File
@@ -0,0 +1,711 @@
//! RFC 9535 filter expressions, for `[?expr]` written without parentheses,
//! and its function extensions, which `?(...)` filters may call too.
use super::Engine;
use super::json::Json;
use super::value::JsValue;
const MAX_DEPTH: usize = 100;
/// An embedded query: `@` or `$` and the segments after it, run by the engine.
#[derive(Debug, Clone)]
pub struct Query {
pub root: bool,
pub path: String,
pub singular: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RfcFunction {
Length,
Count,
Match,
Search,
Value,
}
impl RfcFunction {
pub fn from_name(name: &str) -> Option<Self> {
match name {
"length" => Some(RfcFunction::Length),
"count" => Some(RfcFunction::Count),
"match" => Some(RfcFunction::Match),
"search" => Some(RfcFunction::Search),
"value" => Some(RfcFunction::Value),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
RfcFunction::Length => "length",
RfcFunction::Count => "count",
RfcFunction::Match => "match",
RfcFunction::Search => "search",
RfcFunction::Value => "value",
}
}
pub fn takes_nodes(self) -> bool {
match self {
RfcFunction::Count | RfcFunction::Value => true,
RfcFunction::Length | RfcFunction::Match | RfcFunction::Search => false,
}
}
pub fn arity(self) -> usize {
match self {
RfcFunction::Length | RfcFunction::Count | RfcFunction::Value => 1,
RfcFunction::Match | RfcFunction::Search => 2,
}
}
fn returns_logical(self) -> bool {
match self {
RfcFunction::Match | RfcFunction::Search => true,
RfcFunction::Length | RfcFunction::Count | RfcFunction::Value => false,
}
}
/// The function's result; `None` is RFC 9535's Nothing.
fn call<'a>(
self,
args: &[Option<JsValue<'a>>],
nodes: &[JsValue<'a>],
engine: &Engine<'a>,
) -> Option<JsValue<'a>> {
let arg = |i: usize| args.get(i).cloned().flatten();
match self {
RfcFunction::Length => match arg(0)? {
JsValue::String(s) => Some(JsValue::Number(s.chars().count() as f64)),
JsValue::Doc(Json::Array(items)) => Some(JsValue::Number(items.len() as f64)),
JsValue::Doc(Json::Object(map)) => Some(JsValue::Number(map.len() as f64)),
JsValue::Array(array) => Some(JsValue::Number(array.items.len() as f64)),
_ => None,
},
RfcFunction::Count => Some(JsValue::Number(nodes.len() as f64)),
RfcFunction::Value => match nodes {
[one] => Some(one.clone()),
_ => None,
},
RfcFunction::Match | RfcFunction::Search => {
let matched = match (arg(0), arg(1)) {
(Some(JsValue::String(text)), Some(JsValue::String(pattern))) => engine
.i_regexp(&pattern, self == RfcFunction::Match)
.is_some_and(|re| re.is_match(&text).unwrap_or(false)),
_ => false,
};
Some(JsValue::Bool(matched))
}
}
}
/// A call from a `?(...)` filter, where Nothing is `undefined`.
pub fn call_js<'a>(self, args: &[Vec<JsValue<'a>>], engine: &Engine<'a>) -> JsValue<'a> {
let nodes =
if self.takes_nodes() { args.first().cloned().unwrap_or_default() } else { Vec::new() };
let values: Vec<Option<JsValue<'a>>> = if self.takes_nodes() {
Vec::new()
} else {
args.iter()
.map(|arg| arg.first().cloned().filter(|v| !matches!(v, JsValue::Undefined)))
.collect()
};
self.call(&values, &nodes, engine).unwrap_or(JsValue::Undefined)
}
}
fn is_name_first(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_' || (c as u32) >= 0x80
}
fn is_name_char(c: char) -> bool {
is_name_first(c) || c.is_ascii_digit()
}
fn is_blank(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r')
}
/// The index after the `]` that closes the bracket opened at `open`, skipping quoted text.
pub fn closing_bracket(chars: &[char], open: usize) -> Option<usize> {
let mut depth = 0;
let mut i = open;
while i < chars.len() {
match chars[i] {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
quote @ ('\'' | '"') => {
i += 1;
while i < chars.len() && chars[i] != quote {
i += if chars[i] == '\\' { 2 } else { 1 };
}
}
_ => {}
}
i += 1;
}
None
}
/// Reads the segments after `@` or `$` from `start`, returning where they end.
pub fn scan_query(chars: &[char], start: usize, root: bool) -> (usize, Query) {
let mut end = start;
let mut singular = true;
loop {
let mut i = end;
while chars.get(i).copied().is_some_and(is_blank) {
i += 1;
}
if chars.get(i) == Some(&'.') && chars.get(i + 1) == Some(&'.') {
i += 2;
singular = false;
match chars.get(i) {
Some('*') => i += 1,
Some('[') => match closing_bracket(chars, i) {
Some(close) => i = close,
None => break,
},
Some(c) if is_name_first(*c) => i = name_end(chars, i),
_ => break,
}
} else if chars.get(i) == Some(&'.') {
i += 1;
match chars.get(i) {
Some('*') => {
singular = false;
i += 1;
}
Some(c) if is_name_first(*c) => i = name_end(chars, i),
_ => break,
}
} else if chars.get(i) == Some(&'[') {
let Some(close) = closing_bracket(chars, i) else {
break;
};
let selector: String = chars[i + 1..close - 1].iter().collect();
singular &= is_singular_selector(selector.trim_matches(is_blank));
i = close;
} else {
break;
}
end = i;
}
let path = format!("${}", chars[start..end].iter().collect::<String>());
(end, Query { root, path, singular })
}
fn name_end(chars: &[char], mut i: usize) -> usize {
while chars.get(i).copied().is_some_and(is_name_char) {
i += 1;
}
i
}
fn is_singular_selector(selector: &str) -> bool {
let digits = selector.strip_prefix('-').unwrap_or(selector);
let index = !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit());
let chars: Vec<char> = selector.chars().collect();
let quoted = quoted_string(&chars, 0).is_some_and(|(_, end)| end == chars.len());
index || quoted
}
#[derive(Debug)]
pub enum Logical {
Or(Vec<Logical>),
And(Vec<Logical>),
Not(Box<Logical>),
Compare(Comparable, CompareOp, Comparable),
Exists(Query),
Test(Call),
}
#[derive(Debug, Clone, Copy)]
pub enum CompareOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
#[derive(Debug)]
pub enum Comparable {
Literal(Literal),
Query(Query),
Call(Call),
}
#[derive(Debug)]
pub enum Literal {
Null,
Bool(bool),
Number(f64),
String(String),
}
#[derive(Debug)]
pub struct Call {
function: RfcFunction,
args: Vec<Arg>,
}
#[derive(Debug)]
enum Arg {
Value(Comparable),
Nodes(Query),
}
pub fn parse(text: &str) -> Result<Logical, String> {
let mut parser = Parser { chars: text.chars().collect(), index: 0, depth: 0 };
parser.blank();
let logical = parser.or()?;
parser.blank();
if parser.index < parser.chars.len() {
return Err(parser.error("unexpected"));
}
Ok(logical)
}
struct Parser {
chars: Vec<char>,
index: usize,
depth: usize,
}
impl Parser {
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
}
fn eat(&mut self, s: &str) -> bool {
let matches =
s.chars().enumerate().all(|(i, c)| self.chars.get(self.index + i) == Some(&c));
if matches {
self.index += s.chars().count();
}
matches
}
fn blank(&mut self) {
while self.peek().is_some_and(is_blank) {
self.index += 1;
}
}
fn error(&self, message: &str) -> String {
let rest: String = self.chars[self.index.min(self.chars.len())..].iter().collect();
if rest.is_empty() {
format!("{message} end of filter expression")
} else {
format!("{message} \"{rest}\" in filter expression")
}
}
fn or(&mut self) -> Result<Logical, String> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err("filter expression is nested too deeply".to_string());
}
let mut items = vec![self.and()?];
loop {
self.blank();
if !self.eat("||") {
break;
}
self.blank();
items.push(self.and()?);
}
self.depth -= 1;
Ok(if items.len() == 1 { items.remove(0) } else { Logical::Or(items) })
}
fn and(&mut self) -> Result<Logical, String> {
let mut items = vec![self.basic()?];
loop {
self.blank();
if !self.eat("&&") {
break;
}
self.blank();
items.push(self.basic()?);
}
Ok(if items.len() == 1 { items.remove(0) } else { Logical::And(items) })
}
fn basic(&mut self) -> Result<Logical, String> {
if self.peek() == Some('!') && self.chars.get(self.index + 1) != Some(&'=') {
self.index += 1;
self.blank();
let inner = if self.peek() == Some('(') { self.paren()? } else { self.test()? };
return Ok(Logical::Not(Box::new(inner)));
}
if self.peek() == Some('(') {
return self.paren();
}
let left = self.comparable()?;
let save = self.index;
self.blank();
let op = [
("==", CompareOp::Eq),
("!=", CompareOp::Ne),
("<=", CompareOp::Le),
(">=", CompareOp::Ge),
("<", CompareOp::Lt),
(">", CompareOp::Gt),
]
.into_iter()
.find(|(text, _)| self.eat(text));
let Some((_, op)) = op else {
self.index = save;
return self.as_test(left);
};
self.blank();
let right = self.comparable()?;
for side in [&left, &right] {
check_comparable(side)?;
}
Ok(Logical::Compare(left, op, right))
}
fn paren(&mut self) -> Result<Logical, String> {
self.index += 1;
self.blank();
let inner = self.or()?;
self.blank();
if !self.eat(")") {
return Err(self.error("expected `)` before"));
}
Ok(inner)
}
fn test(&mut self) -> Result<Logical, String> {
let comparable = self.comparable()?;
self.as_test(comparable)
}
fn as_test(&self, comparable: Comparable) -> Result<Logical, String> {
match comparable {
Comparable::Query(query) => Ok(Logical::Exists(query)),
Comparable::Call(call) if call.function.returns_logical() => Ok(Logical::Test(call)),
Comparable::Call(call) => Err(format!(
"{}() gives a value; compare it rather than test it",
call.function.name()
)),
Comparable::Literal(_) => {
Err(self.error("a literal is not a test; expected a comparison before"))
}
}
}
fn comparable(&mut self) -> Result<Comparable, String> {
match self.peek() {
Some(c @ ('@' | '$')) => {
let (end, query) = scan_query(&self.chars, self.index + 1, c == '$');
self.index = end;
Ok(Comparable::Query(query))
}
Some('\'' | '"') => Ok(Comparable::Literal(Literal::String(self.string()?))),
Some(c) if c == '-' || c.is_ascii_digit() => {
Ok(Comparable::Literal(Literal::Number(self.number()?)))
}
Some(c) if c.is_ascii_lowercase() => {
let start = self.index;
while self
.peek()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
self.index += 1;
}
let name: String = self.chars[start..self.index].iter().collect();
if self.peek() == Some('(') {
return self.call(&name).map(Comparable::Call);
}
match name.as_str() {
"true" => Ok(Comparable::Literal(Literal::Bool(true))),
"false" => Ok(Comparable::Literal(Literal::Bool(false))),
"null" => Ok(Comparable::Literal(Literal::Null)),
_ => {
self.index = start;
Err(self.error("unexpected"))
}
}
}
_ => Err(self.error("expected a query, literal or function at")),
}
}
fn call(&mut self, name: &str) -> Result<Call, String> {
let function =
RfcFunction::from_name(name).ok_or_else(|| format!("unknown function {name}()"))?;
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err("filter expression is nested too deeply".to_string());
}
self.index += 1;
let mut args = Vec::new();
loop {
self.blank();
if args.is_empty() && self.eat(")") {
break;
}
let arg = self.comparable()?;
args.push(if function.takes_nodes() {
match arg {
Comparable::Query(query) => Arg::Nodes(query),
Comparable::Literal(_) | Comparable::Call(_) => {
return Err(format!("{}() takes a query", function.name()));
}
}
} else {
check_comparable(&arg)?;
Arg::Value(arg)
});
self.blank();
if self.eat(")") {
break;
}
if !self.eat(",") {
return Err(self.error("expected `,` or `)` before"));
}
}
if args.len() != function.arity() {
return Err(format!("{}() takes {} argument(s)", function.name(), function.arity()));
}
self.depth -= 1;
Ok(Call { function, args })
}
fn number(&mut self) -> Result<f64, String> {
let start = self.index;
self.eat("-");
let digits = |p: &mut Parser| {
let from = p.index;
while p.peek().is_some_and(|c| c.is_ascii_digit()) {
p.index += 1;
}
p.index - from
};
let int_start = self.index;
if digits(self) == 0 || (self.chars[int_start] == '0' && self.index - int_start > 1) {
return Err(self.error("invalid number at"));
}
if self.eat(".") && digits(self) == 0 {
return Err(self.error("invalid number at"));
}
if self.peek().is_some_and(|c| c == 'e' || c == 'E') {
self.index += 1;
if !self.eat("+") {
self.eat("-");
}
if digits(self) == 0 {
return Err(self.error("invalid number at"));
}
}
let text: String = self.chars[start..self.index].iter().collect();
text.parse::<f64>().map_err(|_| self.error("invalid number at"))
}
fn string(&mut self) -> Result<String, String> {
quoted_string(&self.chars, self.index)
.map(|(value, end)| {
self.index = end;
value
})
.ok_or_else(|| self.error("invalid string literal at"))
}
}
/// A quoted name with JSON escapes (and `\'`), and the index after its closing quote.
pub fn quoted_string(chars: &[char], start: usize) -> Option<(String, usize)> {
let quote = *chars.get(start).filter(|c| matches!(c, '\'' | '"'))?;
let mut out = String::new();
let mut i = start + 1;
loop {
let c = *chars.get(i)?;
i += 1;
if c == quote {
return Some((out, i));
}
if c != '\\' {
out.push(c);
continue;
}
let escaped = *chars.get(i)?;
i += 1;
match escaped {
'"' | '\'' | '\\' | '/' => out.push(escaped),
'b' => out.push('\u{8}'),
'f' => out.push('\u{c}'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'u' => {
let hex = |at: usize| -> Option<u16> {
let digits: String = chars.get(at..at + 4)?.iter().collect();
if !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u16::from_str_radix(&digits, 16).ok()
};
let mut units = vec![hex(i)?];
i += 4;
let paired = (0xD800..0xDC00).contains(&units[0])
&& chars.get(i) == Some(&'\\')
&& chars.get(i + 1) == Some(&'u');
if let Some(low) = hex(i + 2).filter(|low| paired && (0xDC00..0xE000).contains(low))
{
units.push(low);
i += 6;
}
out.push_str(&String::from_utf16_lossy(&units));
}
_ => return None,
}
}
}
fn check_comparable(comparable: &Comparable) -> Result<(), String> {
match comparable {
Comparable::Query(query) if !query.singular => Err(format!(
"{}{} can select several nodes, so it cannot be compared",
if query.root { "$" } else { "@" },
&query.path[1..]
)),
Comparable::Call(call) if call.function.returns_logical() => {
Err(format!("{}() gives a test result, not a value to compare", call.function.name()))
}
Comparable::Query(_) | Comparable::Call(_) | Comparable::Literal(_) => Ok(()),
}
}
impl Logical {
pub fn test<'a>(&self, current: &JsValue<'a>, engine: &Engine<'a>) -> Result<bool, String> {
Ok(match self {
Logical::Or(items) => {
for item in items {
if item.test(current, engine)? {
return Ok(true);
}
}
false
}
Logical::And(items) => {
for item in items {
if !item.test(current, engine)? {
return Ok(false);
}
}
true
}
Logical::Not(inner) => !inner.test(current, engine)?,
Logical::Exists(query) => !engine.query(query, current)?.is_empty(),
Logical::Test(call) => call.eval(current, engine)?.is_some_and(|v| v.truthy()),
Logical::Compare(left, op, right) => {
let left = left.eval(current, engine)?;
let right = right.eval(current, engine)?;
let (l, r) = (left.as_ref(), right.as_ref());
match op {
CompareOp::Eq => equal(l, r),
CompareOp::Ne => !equal(l, r),
CompareOp::Lt => less(l, r),
CompareOp::Le => less(l, r) || equal(l, r),
CompareOp::Gt => less(r, l),
CompareOp::Ge => less(r, l) || equal(l, r),
}
}
})
}
}
impl Comparable {
fn eval<'a>(
&self,
current: &JsValue<'a>,
engine: &Engine<'a>,
) -> Result<Option<JsValue<'a>>, String> {
Ok(match self {
Comparable::Literal(Literal::Null) => Some(JsValue::Null),
Comparable::Literal(Literal::Bool(b)) => Some(JsValue::Bool(*b)),
Comparable::Literal(Literal::Number(n)) => Some(JsValue::Number(*n)),
Comparable::Literal(Literal::String(s)) => Some(JsValue::string(s.clone())),
Comparable::Query(query) => match engine.query(query, current)?.as_slice() {
[one] => Some(one.clone()),
_ => None,
},
Comparable::Call(call) => call.eval(current, engine)?,
})
}
}
impl Call {
fn eval<'a>(
&self,
current: &JsValue<'a>,
engine: &Engine<'a>,
) -> Result<Option<JsValue<'a>>, String> {
let mut values = Vec::new();
let mut nodes = Vec::new();
for arg in &self.args {
match arg {
Arg::Value(comparable) => values.push(comparable.eval(current, engine)?),
Arg::Nodes(query) => nodes = engine.query(query, current)?,
}
}
Ok(self.function.call(&values, &nodes, engine))
}
}
fn equal(a: Option<&JsValue<'_>>, b: Option<&JsValue<'_>>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => deep_equal(a, b),
_ => false,
}
}
fn deep_equal(a: &JsValue<'_>, b: &JsValue<'_>) -> bool {
match (a, b) {
(JsValue::Doc(a), JsValue::Doc(b)) => json_equal(a, b),
(JsValue::Null, JsValue::Null) => true,
(JsValue::Bool(a), JsValue::Bool(b)) => a == b,
(JsValue::Number(a), JsValue::Number(b)) => a == b,
(JsValue::String(a), JsValue::String(b)) => a == b,
_ => false,
}
}
fn json_equal(a: &Json, b: &Json) -> bool {
match (a, b) {
(Json::Array(a), Json::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| json_equal(a, b))
}
(Json::Object(a), Json::Object(b)) => {
a.len() == b.len() && a.iter().all(|(k, v)| b.get(k).is_some_and(|w| json_equal(v, w)))
}
(Json::Null, Json::Null) => true,
(Json::Bool(a), Json::Bool(b)) => a == b,
(Json::Number(a), Json::Number(b)) => a == b,
(Json::String(a), Json::String(b)) => a == b,
(Json::Null, _)
| (Json::Bool(_), _)
| (Json::Number(_), _)
| (Json::String(_), _)
| (Json::Array(_), _)
| (Json::Object(_), _) => false,
}
}
fn less(a: Option<&JsValue<'_>>, b: Option<&JsValue<'_>>) -> bool {
match (a, b) {
(Some(JsValue::Number(a)), Some(JsValue::Number(b))) => a < b,
(Some(JsValue::String(a)), Some(JsValue::String(b))) => a < b,
_ => false,
}
}
+803
View File
@@ -0,0 +1,803 @@
//! jsonpath-plus's safe evaluator: jsep's grammar (with its regex plugin,
//! `typeof`, `void`, `undefined`) and Safe-Script's semantics, minus what it
//! would let a filter do beyond reading the document.
use super::Engine;
use super::regex::JsRegex;
use super::rfc::{Query, RfcFunction, scan_query};
use super::value::{JsValue, call_method, get_property, is_allowed_method_name, is_js_whitespace};
use std::rc::Rc;
const MAX_DEPTH: usize = 100;
#[derive(Debug)]
pub enum Expr {
Literal(Literal),
Ident(String),
Member(Box<Expr>, Property),
Call(Box<Expr>, Vec<Expr>),
Rfc(RfcFunction, Vec<RfcArg>),
Array(Vec<Expr>),
Unary(UnaryOp, Box<Expr>),
Binary(BinaryOp, Box<Expr>, Box<Expr>),
Conditional(Box<Expr>, Box<Expr>, Box<Expr>),
}
#[derive(Debug)]
pub enum Literal {
Undefined,
Null,
Bool(bool),
Number(f64),
String(String),
Regex(Rc<JsRegex>),
}
#[derive(Debug)]
pub enum Property {
Name(String),
Computed(Box<Expr>),
}
#[derive(Debug)]
pub enum RfcArg {
Value(Expr),
Nodes(Query),
}
#[derive(Clone, Copy, Debug)]
pub enum UnaryOp {
Neg,
Plus,
Not,
BitNot,
TypeOf,
Void,
}
#[derive(Clone, Copy, Debug)]
pub enum BinaryOp {
Or,
And,
BitOr,
BitXor,
BitAnd,
Eq,
Ne,
StrictEq,
StrictNe,
Lt,
Gt,
Le,
Ge,
Shl,
Shr,
UShr,
Add,
Sub,
Mul,
Div,
Rem,
}
impl BinaryOp {
fn precedence(self) -> u8 {
match self {
BinaryOp::Or => 1,
BinaryOp::And => 2,
BinaryOp::BitOr => 3,
BinaryOp::BitXor => 4,
BinaryOp::BitAnd => 5,
BinaryOp::Eq | BinaryOp::Ne | BinaryOp::StrictEq | BinaryOp::StrictNe => 6,
BinaryOp::Lt | BinaryOp::Gt | BinaryOp::Le | BinaryOp::Ge => 7,
BinaryOp::Shl | BinaryOp::Shr | BinaryOp::UShr => 8,
BinaryOp::Add | BinaryOp::Sub => 9,
BinaryOp::Mul | BinaryOp::Div | BinaryOp::Rem => 10,
}
}
}
const BINARY_OPS: &[(&str, Option<BinaryOp>)] = &[
(">>>=", None),
("===", Some(BinaryOp::StrictEq)),
("!==", Some(BinaryOp::StrictNe)),
(">>>", Some(BinaryOp::UShr)),
("**=", None),
("<<=", None),
(">>=", None),
("||=", None),
("&&=", None),
("??=", None),
("||", Some(BinaryOp::Or)),
("&&", Some(BinaryOp::And)),
("==", Some(BinaryOp::Eq)),
("!=", Some(BinaryOp::Ne)),
("<=", Some(BinaryOp::Le)),
(">=", Some(BinaryOp::Ge)),
("<<", Some(BinaryOp::Shl)),
(">>", Some(BinaryOp::Shr)),
("**", None),
("??", None),
("*=", None),
("/=", None),
("%=", None),
("+=", None),
("-=", None),
("&=", None),
("^=", None),
("|=", None),
("|", Some(BinaryOp::BitOr)),
("^", Some(BinaryOp::BitXor)),
("&", Some(BinaryOp::BitAnd)),
("<", Some(BinaryOp::Lt)),
(">", Some(BinaryOp::Gt)),
("+", Some(BinaryOp::Add)),
("-", Some(BinaryOp::Sub)),
("*", Some(BinaryOp::Mul)),
("/", Some(BinaryOp::Div)),
("%", Some(BinaryOp::Rem)),
("=", None),
];
const UNARY_OPS: &[(&str, UnaryOp)] = &[
("typeof", UnaryOp::TypeOf),
("void", UnaryOp::Void),
("-", UnaryOp::Neg),
("!", UnaryOp::Not),
("~", UnaryOp::BitNot),
("+", UnaryOp::Plus),
];
pub fn parse(code: &str) -> Result<Expr, String> {
let mut parser = Parser { chars: code.chars().collect(), index: 0, depth: 0 };
let mut nodes = parser.expressions(None)?;
match nodes.len() {
0 => Ok(Expr::Literal(Literal::Undefined)),
1 => Ok(nodes.remove(0)),
_ => match &nodes[0] {
Expr::Ident(name) if matches!(name.as_str(), "var" | "let" | "const") => {
Err(format!("variable declarations ({name}) are not supported"))
}
_ => {
Err("multiple expressions (separated by `;` or `,`) are not supported".to_string())
}
},
}
}
fn is_identifier_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '$' || c == '_' || (c as u32) >= 128
}
fn is_identifier_part(c: char) -> bool {
is_identifier_start(c) || c.is_ascii_digit()
}
struct Parser {
chars: Vec<char>,
index: usize,
depth: usize,
}
impl Parser {
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
}
fn peek_at(&self, offset: usize) -> Option<char> {
self.chars.get(self.index + offset).copied()
}
fn rest_starts_with(&self, s: &str) -> bool {
s.chars().enumerate().all(|(i, c)| self.peek_at(i) == Some(c))
}
fn error(&self, message: &str) -> String {
format!("{message} at character {}", self.index)
}
fn unexpected(&self) -> String {
self.error(&format!("Unexpected \"{}\"", self.peek().map(String::from).unwrap_or_default()))
}
fn spaces(&mut self) {
while let Some(' ' | '\t' | '\n' | '\r') = self.peek() {
self.index += 1;
}
}
fn enter(&mut self) -> Result<(), String> {
self.depth += 1;
if self.depth > MAX_DEPTH {
Err("expression is nested too deeply".to_string())
} else {
Ok(())
}
}
fn expressions(&mut self, until: Option<char>) -> Result<Vec<Expr>, String> {
let mut nodes = Vec::new();
while let Some(c) = self.peek() {
if c == ';' || c == ',' {
self.index += 1;
continue;
}
match self.expression()? {
Some(node) => nodes.push(node),
None if self.index < self.chars.len() => {
if Some(c) == until {
break;
}
return Err(self.unexpected());
}
None => {}
}
}
Ok(nodes)
}
fn expression(&mut self) -> Result<Option<Expr>, String> {
self.enter()?;
let node = self.binary_expression()?;
self.spaces();
let node = match node {
Some(test) if self.peek() == Some('?') => {
self.index += 1;
let consequent =
self.expression()?.ok_or_else(|| self.error("Expected expression"))?;
self.spaces();
if self.peek() != Some(':') {
return Err(self.error("Expected :"));
}
self.index += 1;
let alternate =
self.expression()?.ok_or_else(|| self.error("Expected expression"))?;
Some(Expr::Conditional(Box::new(test), Box::new(consequent), Box::new(alternate)))
}
node => node,
};
self.depth -= 1;
Ok(node)
}
fn binary_op(&mut self) -> Result<Option<BinaryOp>, String> {
self.spaces();
let Some((text, op)) = BINARY_OPS.iter().find(|(text, _)| self.rest_starts_with(text))
else {
return Ok(None);
};
match op {
Some(op) => {
self.index += text.chars().count();
Ok(Some(*op))
}
None if text.ends_with('=') => Err(format!("assignment ({text}) is not supported")),
None => Err(format!("the {text} operator is not supported")),
}
}
fn binary_expression(&mut self) -> Result<Option<Expr>, String> {
enum Item {
Node(Expr),
Op(BinaryOp),
}
let Some(left) = self.token()? else {
return Ok(None);
};
let Some(op) = self.binary_op()? else {
return Ok(Some(left));
};
let right =
self.token()?.ok_or_else(|| self.error("Expected expression after operator"))?;
let mut stack = vec![Item::Node(left), Item::Op(op), Item::Node(right)];
let mut operators = 1;
while let Some(op) = self.binary_op()? {
operators += 1;
if operators > MAX_DEPTH {
return Err("expression has too many operators".to_string());
}
while stack.len() > 2
&& matches!(&stack[stack.len() - 2], Item::Op(prev) if op.precedence() <= prev.precedence())
{
let (Some(Item::Node(right)), Some(Item::Op(prev)), Some(Item::Node(left))) =
(stack.pop(), stack.pop(), stack.pop())
else {
return Err(self.error("malformed expression"));
};
stack.push(Item::Node(Expr::Binary(prev, Box::new(left), Box::new(right))));
}
let node =
self.token()?.ok_or_else(|| self.error("Expected expression after operator"))?;
stack.push(Item::Op(op));
stack.push(Item::Node(node));
}
let mut node = match stack.pop() {
Some(Item::Node(node)) => node,
_ => return Err(self.error("malformed expression")),
};
while let (Some(Item::Op(op)), Some(Item::Node(left))) = (stack.pop(), stack.pop()) {
node = Expr::Binary(op, Box::new(left), Box::new(node));
}
Ok(Some(node))
}
fn after_token(&self, node: Option<Expr>) -> Result<Option<Expr>, String> {
if node.is_some() && (self.rest_starts_with("++") || self.rest_starts_with("--")) {
return Err("increment and decrement (++, --) are not supported".to_string());
}
Ok(node)
}
fn token(&mut self) -> Result<Option<Expr>, String> {
self.spaces();
self.enter()?;
let node = self.token_inner();
self.depth -= 1;
node
}
fn token_inner(&mut self) -> Result<Option<Expr>, String> {
if self.peek() == Some('/') {
let regex = self.regex_literal()?;
let node = self.token_property(regex)?;
return self.after_token(Some(node));
}
if self.rest_starts_with("++") || self.rest_starts_with("--") {
return Err("increment and decrement (++, --) are not supported".to_string());
}
let Some(c) = self.peek() else {
return Ok(None);
};
if c.is_ascii_digit() || c == '.' {
return self.number().map(Some);
}
let node = if c == '\'' || c == '"' {
Expr::Literal(Literal::String(self.string_literal()?))
} else if c == '[' {
self.index += 1;
let items = self.arguments(']')?;
Expr::Array(items)
} else {
for (text, op) in UNARY_OPS {
let len = text.chars().count();
let word = is_identifier_start(c);
if self.rest_starts_with(text)
&& (!word || self.peek_at(len).is_some_and(|next| !is_identifier_part(next)))
{
self.index += len;
let argument =
self.token()?.ok_or_else(|| self.error("missing unaryOp argument"))?;
return self.after_token(Some(Expr::Unary(*op, Box::new(argument))));
}
}
if is_identifier_start(c) {
let name = self.identifier()?;
match name.as_str() {
"true" => Expr::Literal(Literal::Bool(true)),
"false" => Expr::Literal(Literal::Bool(false)),
"null" => Expr::Literal(Literal::Null),
"undefined" => Expr::Literal(Literal::Undefined),
"this" => return Err("`this` is not supported".to_string()),
_ => Expr::Ident(name),
}
} else if c == '(' {
match self.group()? {
Some(node) => node,
None => return Ok(None),
}
} else {
return Ok(None);
}
};
let node = self.token_property(node)?;
self.after_token(Some(node))
}
fn token_property(&mut self, mut node: Expr) -> Result<Expr, String> {
self.spaces();
let depth = self.depth;
while let Some(mut c) = self.peek() {
if !matches!(c, '.' | '[' | '(' | '?') {
break;
}
self.enter()?;
let mut optional = false;
if c == '?' {
if self.peek_at(1) != Some('.') {
break;
}
optional = true;
self.index += 2;
self.spaces();
c = self.peek().unwrap_or(' ');
}
self.index += 1;
if c == '[' {
let property = self.expression()?.ok_or_else(|| self.unexpected())?;
self.spaces();
if self.peek() != Some(']') {
return Err(self.error("Unclosed ["));
}
self.index += 1;
node = Expr::Member(Box::new(node), Property::Computed(Box::new(property)));
} else if c == '(' {
node = self.call(node)?;
} else if c == '.' || optional {
if optional {
self.index -= 1;
}
self.spaces();
let name = self.identifier()?;
node = Expr::Member(Box::new(node), Property::Name(name));
}
self.spaces();
}
self.depth = depth;
Ok(node)
}
fn call(&mut self, callee: Expr) -> Result<Expr, String> {
let function = match &callee {
Expr::Ident(name) => match RfcFunction::from_name(name) {
Some(function) => function,
None => return Err(format!("unknown function {name}()")),
},
Expr::Member(_, Property::Name(name)) if !is_allowed_method_name(name) => {
return Err(format!("unsupported method {name}()"));
}
_ => {
let args = self.arguments(')')?;
return Ok(Expr::Call(Box::new(callee), args));
}
};
if !function.takes_nodes() {
let args = self.arguments(')')?;
if args.len() != function.arity() {
return Err(format!(
"{}() takes {} argument(s)",
function.name(),
function.arity()
));
}
return Ok(Expr::Rfc(function, args.into_iter().map(RfcArg::Value).collect()));
}
self.spaces();
let start = self.index;
let (prefix_len, root) = if self.rest_starts_with("_$_root") {
(7, true)
} else if self.rest_starts_with("_$_v") {
(4, false)
} else if self.rest_starts_with("@") {
(1, false)
} else if self.rest_starts_with("$") {
(1, true)
} else {
return Err(self.error(&format!("{}() takes a query (@ or $)", function.name())));
};
if self.peek_at(prefix_len).is_some_and(|c| is_identifier_part(c) && c != '$') {
return Err(self.error(&format!("{}() takes a query (@ or $)", function.name())));
}
let (end, query) = scan_query(&self.chars, start + prefix_len, root);
self.index = end;
self.spaces();
if self.peek() != Some(')') {
return Err(self.error(&format!("{}() takes one query", function.name())));
}
self.index += 1;
Ok(Expr::Rfc(function, vec![RfcArg::Nodes(query)]))
}
fn number(&mut self) -> Result<Expr, String> {
let mut number = String::new();
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
number.push(c);
self.index += 1;
}
if self.peek() == Some('.') {
number.push('.');
self.index += 1;
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
number.push(c);
self.index += 1;
}
}
if let Some(e @ ('e' | 'E')) = self.peek() {
number.push(e);
self.index += 1;
if let Some(sign @ ('+' | '-')) = self.peek() {
number.push(sign);
self.index += 1;
}
while let Some(c) = self.peek().filter(char::is_ascii_digit) {
number.push(c);
self.index += 1;
}
if !number.ends_with(|c: char| c.is_ascii_digit()) {
return Err(self.error(&format!("Expected exponent ({number})")));
}
}
match self.peek() {
Some(c) if is_identifier_start(c) => {
return Err(
self.error(&format!("Variable names cannot start with a number ({number}{c})"))
);
}
Some('.') => return Err(self.error("Unexpected period")),
_ if number == "." => return Err(self.error("Unexpected period")),
_ => {}
}
let mantissa = number.split(['e', 'E']).next().unwrap_or("");
let value = if mantissa.chars().any(|c| c.is_ascii_digit()) {
number
.trim_end_matches('.')
.replace(".e", "e")
.replace(".E", "E")
.parse::<f64>()
.unwrap_or(f64::NAN)
} else {
f64::NAN
};
Ok(Expr::Literal(Literal::Number(value)))
}
fn string_literal(&mut self) -> Result<String, String> {
let quote = self.peek();
self.index += 1;
let mut out = String::new();
while let Some(c) = self.peek() {
self.index += 1;
if Some(c) == quote {
return Ok(out);
}
if c == '\\' {
let escaped = self.peek();
self.index += 1;
match escaped {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('b') => out.push('\u{8}'),
Some('f') => out.push('\u{c}'),
Some('v') => out.push('\u{b}'),
Some(other) => out.push(other),
None => {}
}
} else {
out.push(c);
}
}
Err(self.error(&format!("Unclosed quote after \"{out}\"")))
}
fn regex_literal(&mut self) -> Result<Expr, String> {
self.index += 1;
let start = self.index;
let mut in_class = false;
while let Some(c) = self.peek() {
if c == '/' && !in_class {
let pattern: String = self.chars[start..self.index].iter().collect();
self.index += 1;
let mut flags = String::new();
while let Some(f) = self.peek().filter(char::is_ascii_alphanumeric) {
flags.push(f);
self.index += 1;
}
let regex = JsRegex::new(&pattern, &flags).map_err(|e| self.error(&e))?;
return Ok(Expr::Literal(Literal::Regex(Rc::new(regex))));
}
if c == '[' {
in_class = true;
} else if in_class && c == ']' {
in_class = false;
}
self.index += if c == '\\' { 2 } else { 1 };
}
Err(self.error("Unclosed Regex"))
}
fn identifier(&mut self) -> Result<String, String> {
match self.peek() {
Some(c) if is_identifier_start(c) => self.index += 1,
_ => {
return Err(self.error(&format!(
"Unexpected {}",
self.peek().map(String::from).unwrap_or_default()
)));
}
}
let start = self.index - 1;
while self.peek().is_some_and(is_identifier_part) {
self.index += 1;
}
Ok(self.chars[start..self.index].iter().collect())
}
fn arguments(&mut self, terminator: char) -> Result<Vec<Expr>, String> {
let mut args: Vec<Option<Expr>> = Vec::new();
let mut separators = 0;
loop {
self.spaces();
let Some(c) = self.peek() else {
return Err(self.error(&format!("Expected {terminator}")));
};
if c == terminator {
self.index += 1;
if terminator == ')' && separators > 0 && separators >= args.len() {
return Err(self.error("Unexpected token )"));
}
break;
} else if c == ',' {
self.index += 1;
separators += 1;
if separators != args.len() {
if terminator == ')' {
return Err(self.error("Unexpected token ,"));
}
while args.len() < separators {
args.push(None);
}
}
} else if args.len() != separators && separators != 0 {
return Err(self.error("Expected comma"));
} else {
let node = self.expression()?.ok_or_else(|| self.error("Expected comma"))?;
args.push(Some(node));
}
}
args.into_iter()
.map(|arg| arg.ok_or_else(|| "array holes are not supported".to_string()))
.collect()
}
fn group(&mut self) -> Result<Option<Expr>, String> {
self.index += 1;
let mut nodes = self.expressions(Some(')'))?;
if self.peek() != Some(')') {
return Err(self.error("Unclosed ("));
}
self.index += 1;
match nodes.len() {
0 => Ok(None),
1 => Ok(Some(nodes.remove(0))),
_ => Err("sequence expressions (a, b) are not supported".to_string()),
}
}
}
/// jsonpath-plus's textual substitution of `@` forms before jsep sees the code.
pub fn substitute(code: &str) -> String {
let code = code
.replace("@parentProperty", "_$_parentProperty")
.replace("@parent", "_$_parent")
.replace("@property", "_$_property")
.replace("@root", "_$_root");
let mut out = String::with_capacity(code.len());
let mut chars = code.chars().peekable();
while let Some(c) = chars.next() {
if c == '@'
&& chars.peek().is_some_and(|n| matches!(n, '.' | ')' | '[') || is_js_whitespace(*n))
{
out.push_str("_$_v");
} else {
out.push(c);
}
}
if code.contains("@path") { out.replace("@path", "_$_path") } else { out }
}
pub struct Scope<'a> {
pub value: JsValue<'a>,
pub property: JsValue<'a>,
pub parent: JsValue<'a>,
pub parent_property: JsValue<'a>,
pub path: Option<String>,
}
pub fn eval<'a>(
expr: &Expr,
scope: &Scope<'a>,
engine: &Engine<'a>,
) -> Result<JsValue<'a>, String> {
Ok(match expr {
Expr::Literal(literal) => match literal {
Literal::Undefined => JsValue::Undefined,
Literal::Null => JsValue::Null,
Literal::Bool(b) => JsValue::Bool(*b),
Literal::Number(n) => JsValue::Number(*n),
Literal::String(s) => JsValue::string(s.clone()),
Literal::Regex(re) => JsValue::Regex(re.clone()),
},
Expr::Ident(name) => match name.as_str() {
"_$_v" => scope.value.clone(),
"_$_property" => scope.property.clone(),
"_$_parent" => scope.parent.clone(),
"_$_parentProperty" => scope.parent_property.clone(),
"_$_root" => JsValue::from_doc(engine.root),
"_$_path" if scope.path.is_some() => {
JsValue::string(scope.path.clone().unwrap_or_default())
}
_ => return Err(format!("{name} is not defined")),
},
Expr::Member(object, property) => {
let key = match property {
Property::Name(name) => name.clone(),
Property::Computed(expr) => eval(expr, scope, engine)?.to_js_string(),
};
let object = eval(object, scope, engine)?;
get_property(&object, &key)?
}
Expr::Call(callee, args) => {
let args =
args.iter().map(|arg| eval(arg, scope, engine)).collect::<Result<Vec<_>, _>>()?;
match eval(callee, scope, engine)? {
JsValue::Function(bound) => call_method(&bound, &args)?,
other => return Err(format!("{} is not a function", other.type_of())),
}
}
Expr::Rfc(function, args) => {
let args = args
.iter()
.map(|arg| match arg {
RfcArg::Value(expr) => eval(expr, scope, engine).map(|v| vec![v]),
RfcArg::Nodes(query) => engine.query(query, &scope.value),
})
.collect::<Result<Vec<_>, _>>()?;
function.call_js(&args, engine)
}
Expr::Array(items) => JsValue::array(
items.iter().map(|item| eval(item, scope, engine)).collect::<Result<_, _>>()?,
),
Expr::Unary(op, argument) => {
let value = eval(argument, scope, engine)?;
match op {
UnaryOp::Neg => JsValue::Number(-value.to_number()),
UnaryOp::Plus => JsValue::Number(value.to_number()),
UnaryOp::Not => JsValue::Bool(!value.truthy()),
UnaryOp::BitNot => JsValue::Number(f64::from(!value.to_int32())),
UnaryOp::TypeOf => JsValue::string(value.type_of()),
UnaryOp::Void => JsValue::Undefined,
}
}
Expr::Binary(op, left, right) => {
let left = eval(left, scope, engine)?;
if matches!(op, BinaryOp::Or | BinaryOp::And) {
let short_circuits = left.truthy() == matches!(op, BinaryOp::Or);
return if short_circuits { Ok(left) } else { eval(right, scope, engine) };
}
let right = eval(right, scope, engine)?;
binary(*op, &left, &right)
}
Expr::Conditional(test, consequent, alternate) => {
if eval(test, scope, engine)?.truthy() {
eval(consequent, scope, engine)?
} else {
eval(alternate, scope, engine)?
}
}
})
}
fn binary<'a>(op: BinaryOp, a: &JsValue<'a>, b: &JsValue<'a>) -> JsValue<'a> {
match op {
BinaryOp::Or | BinaryOp::And => JsValue::Undefined,
BinaryOp::BitOr => JsValue::Number(f64::from(a.to_int32() | b.to_int32())),
BinaryOp::BitXor => JsValue::Number(f64::from(a.to_int32() ^ b.to_int32())),
BinaryOp::BitAnd => JsValue::Number(f64::from(a.to_int32() & b.to_int32())),
BinaryOp::Eq => JsValue::Bool(a.loose_eq(b)),
BinaryOp::Ne => JsValue::Bool(!a.loose_eq(b)),
BinaryOp::StrictEq => JsValue::Bool(a.strict_eq(b)),
BinaryOp::StrictNe => JsValue::Bool(!a.strict_eq(b)),
BinaryOp::Lt => JsValue::Bool(a.less_than(b) == Some(true)),
BinaryOp::Gt => JsValue::Bool(b.less_than(a) == Some(true)),
BinaryOp::Le => JsValue::Bool(b.less_than(a) == Some(false)),
BinaryOp::Ge => JsValue::Bool(a.less_than(b) == Some(false)),
BinaryOp::Shl => JsValue::Number(f64::from(a.to_int32().wrapping_shl(b.to_uint32() & 31))),
BinaryOp::Shr => JsValue::Number(f64::from(a.to_int32() >> (b.to_uint32() & 31))),
BinaryOp::UShr => JsValue::Number(f64::from(a.to_uint32() >> (b.to_uint32() & 31))),
BinaryOp::Add => a.add(b),
BinaryOp::Sub => JsValue::Number(a.to_number() - b.to_number()),
BinaryOp::Mul => JsValue::Number(a.to_number() * b.to_number()),
BinaryOp::Div => JsValue::Number(a.to_number() / b.to_number()),
BinaryOp::Rem => JsValue::Number(a.to_number() % b.to_number()),
}
}
+953
View File
@@ -0,0 +1,953 @@
//! The slice of JavaScript values and coercions the safe evaluator needs.
use super::json::{Json, array_index, number_to_string};
use super::regex::JsRegex;
use std::borrow::Cow;
use std::cmp::Ordering;
use std::rc::Rc;
#[derive(Clone, Debug)]
pub enum JsValue<'a> {
Undefined,
Null,
Bool(bool),
Number(f64),
String(Cow<'a, str>),
/// An array or object of the document, compared by identity.
Doc(&'a Json),
Array(Rc<JsArray<'a>>),
Regex(Rc<JsRegex>),
Function(Rc<BoundMethod<'a>>),
}
#[derive(Debug, Default)]
pub struct JsArray<'a> {
pub items: Vec<JsValue<'a>>,
/// Set on the array `String.prototype.match` returns.
pub match_info: Option<MatchInfo>,
}
#[derive(Debug)]
pub struct MatchInfo {
pub index: f64,
pub input: String,
}
#[derive(Debug)]
pub struct BoundMethod<'a> {
pub receiver: JsValue<'a>,
pub method: Method,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
StringIncludes,
StringStartsWith,
StringEndsWith,
StringIndexOf,
StringLastIndexOf,
StringToLowerCase,
StringToUpperCase,
StringTrim,
StringTrimStart,
StringTrimEnd,
StringSlice,
StringSubstring,
StringSplit,
StringMatch,
StringToString,
ArrayIncludes,
ArrayIndexOf,
ArrayJoin,
ArrayToString,
NumberToString,
NumberToFixed,
RegexTest,
}
impl Method {
pub fn name(self) -> &'static str {
match self {
Method::StringIncludes | Method::ArrayIncludes => "includes",
Method::StringStartsWith => "startsWith",
Method::StringEndsWith => "endsWith",
Method::StringIndexOf | Method::ArrayIndexOf => "indexOf",
Method::StringLastIndexOf => "lastIndexOf",
Method::StringToLowerCase => "toLowerCase",
Method::StringToUpperCase => "toUpperCase",
Method::StringTrim => "trim",
Method::StringTrimStart => "trimStart",
Method::StringTrimEnd => "trimEnd",
Method::StringSlice => "slice",
Method::StringSubstring => "substring",
Method::StringSplit => "split",
Method::StringMatch => "match",
Method::StringToString | Method::ArrayToString | Method::NumberToString => "toString",
Method::ArrayJoin => "join",
Method::NumberToFixed => "toFixed",
Method::RegexTest => "test",
}
}
}
const STRING_METHODS: &[(&str, Method)] = &[
("includes", Method::StringIncludes),
("startsWith", Method::StringStartsWith),
("endsWith", Method::StringEndsWith),
("indexOf", Method::StringIndexOf),
("lastIndexOf", Method::StringLastIndexOf),
("toLowerCase", Method::StringToLowerCase),
("toUpperCase", Method::StringToUpperCase),
("trim", Method::StringTrim),
("trimStart", Method::StringTrimStart),
("trimEnd", Method::StringTrimEnd),
("slice", Method::StringSlice),
("substring", Method::StringSubstring),
("split", Method::StringSplit),
("match", Method::StringMatch),
("toString", Method::StringToString),
];
const ARRAY_METHODS: &[(&str, Method)] = &[
("includes", Method::ArrayIncludes),
("indexOf", Method::ArrayIndexOf),
("join", Method::ArrayJoin),
("toString", Method::ArrayToString),
];
const NUMBER_METHODS: &[(&str, Method)] = &[
("toString", Method::NumberToString),
("toFixed", Method::NumberToFixed),
];
const REGEX_METHODS: &[(&str, Method)] = &[("test", Method::RegexTest)];
/// Every method name a call may use; anything else is rejected before evaluation.
pub fn is_allowed_method_name(name: &str) -> bool {
[STRING_METHODS, ARRAY_METHODS, NUMBER_METHODS, REGEX_METHODS]
.iter()
.any(|t| t.iter().any(|(n, _)| *n == name))
}
const STRING_PROTO: &[&str] = &[
"anchor",
"at",
"big",
"blink",
"bold",
"charAt",
"charCodeAt",
"codePointAt",
"concat",
"fontcolor",
"fontsize",
"fixed",
"isWellFormed",
"italics",
"link",
"localeCompare",
"matchAll",
"normalize",
"padEnd",
"padStart",
"repeat",
"replace",
"replaceAll",
"search",
"small",
"strike",
"sub",
"substr",
"sup",
"toWellFormed",
"trimLeft",
"trimRight",
"toLocaleLowerCase",
"toLocaleUpperCase",
"valueOf",
];
const ARRAY_PROTO: &[&str] = &[
"at",
"concat",
"copyWithin",
"fill",
"find",
"findIndex",
"findLast",
"findLastIndex",
"lastIndexOf",
"pop",
"push",
"reverse",
"shift",
"unshift",
"slice",
"sort",
"splice",
"keys",
"entries",
"values",
"forEach",
"filter",
"flat",
"flatMap",
"map",
"every",
"some",
"reduce",
"reduceRight",
"toReversed",
"toSorted",
"toSpliced",
"with",
"toLocaleString",
];
const NUMBER_PROTO: &[&str] = &["toExponential", "toPrecision", "valueOf", "toLocaleString"];
const BOOLEAN_PROTO: &[&str] = &["toString", "valueOf"];
const REGEX_PROTO: &[&str] = &["exec", "compile", "toString"];
const FUNCTION_PROTO: &[&str] = &[
"length",
"name",
"arguments",
"caller",
"apply",
"bind",
"call",
"toString",
];
const OBJECT_PROTO: &[&str] = &[
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toString",
"valueOf",
"toLocaleString",
];
/// Reading these off a prototype is refused, as jsonpath-plus's safe evaluator does.
const BLOCKED: &[&str] = &[
"constructor",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
];
pub fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'
..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
impl<'a> JsValue<'a> {
pub fn from_doc(json: &'a Json) -> Self {
match json {
Json::Null => JsValue::Null,
Json::Bool(b) => JsValue::Bool(*b),
Json::Number(n) => JsValue::Number(*n),
Json::String(s) => JsValue::String(Cow::Borrowed(s)),
Json::Array(_) | Json::Object(_) => JsValue::Doc(json),
}
}
pub fn string(s: impl Into<String>) -> Self {
JsValue::String(Cow::Owned(s.into()))
}
pub fn array(items: Vec<JsValue<'a>>) -> Self {
JsValue::Array(Rc::new(JsArray { items, match_info: None }))
}
pub fn truthy(&self) -> bool {
match self {
JsValue::Undefined | JsValue::Null => false,
JsValue::Bool(b) => *b,
JsValue::Number(n) => *n != 0.0 && !n.is_nan(),
JsValue::String(s) => !s.is_empty(),
JsValue::Doc(_) | JsValue::Array(_) | JsValue::Regex(_) | JsValue::Function(_) => true,
}
}
pub fn is_object(&self) -> bool {
match self {
JsValue::Doc(_) | JsValue::Array(_) | JsValue::Regex(_) | JsValue::Function(_) => true,
JsValue::Undefined
| JsValue::Null
| JsValue::Bool(_)
| JsValue::Number(_)
| JsValue::String(_) => false,
}
}
pub fn type_of(&self) -> &'static str {
match self {
JsValue::Undefined => "undefined",
JsValue::Null | JsValue::Doc(_) | JsValue::Array(_) | JsValue::Regex(_) => "object",
JsValue::Bool(_) => "boolean",
JsValue::Number(_) => "number",
JsValue::String(_) => "string",
JsValue::Function(_) => "function",
}
}
/// `ToPrimitive`: every object here stringifies, whatever the hint.
pub fn to_primitive(&self) -> JsValue<'a> {
if self.is_object() { JsValue::string(self.to_js_string()) } else { self.clone() }
}
pub fn to_number(&self) -> f64 {
match self {
JsValue::Undefined => f64::NAN,
JsValue::Null => 0.0,
JsValue::Bool(b) => f64::from(u8::from(*b)),
JsValue::Number(n) => *n,
JsValue::String(s) => string_to_number(s),
JsValue::Doc(_) | JsValue::Array(_) | JsValue::Regex(_) | JsValue::Function(_) => {
string_to_number(&self.to_js_string())
}
}
}
pub fn to_js_string(&self) -> String {
match self {
JsValue::Undefined => "undefined".to_string(),
JsValue::Null => "null".to_string(),
JsValue::Bool(b) => b.to_string(),
JsValue::Number(n) => number_to_string(*n),
JsValue::String(s) => s.to_string(),
JsValue::Doc(Json::Array(items)) => join_doc(items, ","),
JsValue::Doc(_) => "[object Object]".to_string(),
JsValue::Array(array) => join_values(&array.items, ","),
JsValue::Regex(re) => format!("/{}/{}", re.source, re.flags),
JsValue::Function(_) => "function () { [native code] }".to_string(),
}
}
pub fn to_int32(&self) -> i32 {
self.to_uint32() as i32
}
pub fn to_uint32(&self) -> u32 {
let n = self.to_number();
if !n.is_finite() {
return 0;
}
n.trunc().rem_euclid(4_294_967_296.0) as u32
}
/// `ToIntegerOrInfinity`.
pub fn to_integer(&self) -> f64 {
let n = self.to_number();
if n.is_nan() { 0.0 } else { n.trunc() + 0.0 }
}
fn same_object(&self, other: &JsValue<'a>) -> bool {
match (self, other) {
(JsValue::Doc(a), JsValue::Doc(b)) => std::ptr::eq(*a, *b),
(JsValue::Array(a), JsValue::Array(b)) => Rc::ptr_eq(a, b),
(JsValue::Regex(a), JsValue::Regex(b)) => Rc::ptr_eq(a, b),
(JsValue::Function(a), JsValue::Function(b)) => Rc::ptr_eq(a, b),
_ => false,
}
}
pub fn strict_eq(&self, other: &JsValue<'a>) -> bool {
match (self, other) {
(JsValue::Undefined, JsValue::Undefined) | (JsValue::Null, JsValue::Null) => true,
(JsValue::Bool(a), JsValue::Bool(b)) => a == b,
(JsValue::Number(a), JsValue::Number(b)) => a == b,
(JsValue::String(a), JsValue::String(b)) => a == b,
(a, b) => a.same_object(b),
}
}
/// `SameValueZero`, for `Array.prototype.includes`.
fn same_value_zero(&self, other: &JsValue<'a>) -> bool {
match (self, other) {
(JsValue::Number(a), JsValue::Number(b)) if a.is_nan() && b.is_nan() => true,
(a, b) => a.strict_eq(b),
}
}
pub fn loose_eq(&self, other: &JsValue<'a>) -> bool {
use JsValue::{Bool, Null, Number, String, Undefined};
match (self, other) {
(Undefined | Null, Undefined | Null) => true,
(Undefined | Null, _) | (_, Undefined | Null) => false,
(Number(a), String(b)) => *a == string_to_number(b),
(String(a), Number(b)) => string_to_number(a) == *b,
(Bool(_), _) => JsValue::Number(self.to_number()).loose_eq(other),
(_, Bool(_)) => self.loose_eq(&JsValue::Number(other.to_number())),
(a, b) if a.is_object() && !b.is_object() => a.to_primitive().loose_eq(b),
(a, b) if !a.is_object() && b.is_object() => a.loose_eq(&b.to_primitive()),
(a, b) => a.strict_eq(b),
}
}
/// `IsLessThan`; `None` is JavaScript's undefined, from NaN.
pub fn less_than(&self, other: &JsValue<'a>) -> Option<bool> {
let (a, b) = (self.to_primitive(), other.to_primitive());
if let (JsValue::String(a), JsValue::String(b)) = (&a, &b) {
return Some(a.encode_utf16().cmp(b.encode_utf16()) == Ordering::Less);
}
let (a, b) = (a.to_number(), b.to_number());
if a.is_nan() || b.is_nan() { None } else { Some(a < b) }
}
pub fn add(&self, other: &JsValue<'a>) -> JsValue<'a> {
let (a, b) = (self.to_primitive(), other.to_primitive());
if matches!(a, JsValue::String(_)) || matches!(b, JsValue::String(_)) {
JsValue::string(a.to_js_string() + &b.to_js_string())
} else {
JsValue::Number(a.to_number() + b.to_number())
}
}
}
fn join_doc(items: &[Json], separator: &str) -> String {
let parts: Vec<String> = items
.iter()
.map(|item| match item {
Json::Null => String::new(),
item => JsValue::from_doc(item).to_js_string(),
})
.collect();
parts.join(separator)
}
fn join_values(items: &[JsValue<'_>], separator: &str) -> String {
let parts: Vec<String> = items
.iter()
.map(|item| match item {
JsValue::Undefined | JsValue::Null => String::new(),
item => item.to_js_string(),
})
.collect();
parts.join(separator)
}
/// `StringToNumber`.
pub fn string_to_number(s: &str) -> f64 {
let s = s.trim_matches(is_js_whitespace);
if s.is_empty() {
return 0.0;
}
for (prefix, radix) in [
("0x", 16),
("0X", 16),
("0o", 8),
("0O", 8),
("0b", 2),
("0B", 2),
] {
if let Some(digits) = s.strip_prefix(prefix) {
if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
return f64::NAN;
}
return digits.chars().fold(0.0, |n, c| {
n * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0))
});
}
}
let unsigned = s.strip_prefix(['+', '-']).unwrap_or(s);
if unsigned == "Infinity" {
return if s.starts_with('-') { f64::NEG_INFINITY } else { f64::INFINITY };
}
let bytes = unsigned.as_bytes();
let mut i = 0;
let int_digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
i += int_digits;
let mut frac_digits = 0;
if bytes.get(i) == Some(&b'.') {
i += 1;
frac_digits = bytes[i..].iter().take_while(|b| b.is_ascii_digit()).count();
i += frac_digits;
}
if int_digits + frac_digits == 0 {
return f64::NAN;
}
if let Some(b'e' | b'E') = bytes.get(i) {
i += 1;
if let Some(b'+' | b'-') = bytes.get(i) {
i += 1;
}
let exp_digits = bytes[i..].iter().take_while(|b| b.is_ascii_digit()).count();
if exp_digits == 0 {
return f64::NAN;
}
i += exp_digits;
}
if i != bytes.len() {
return f64::NAN;
}
s.parse::<f64>().unwrap_or(f64::NAN)
}
/// An own property, as `Object.hasOwn` sees it.
pub fn own_property<'a>(value: &JsValue<'a>, key: &str) -> Option<JsValue<'a>> {
match value {
JsValue::Doc(Json::Object(map)) => map.get(key).map(JsValue::from_doc),
JsValue::Doc(Json::Array(items)) => {
if key == "length" {
return Some(JsValue::Number(items.len() as f64));
}
array_index(key).and_then(|i| items.get(i as usize)).map(JsValue::from_doc)
}
JsValue::Array(array) => {
if key == "length" {
return Some(JsValue::Number(array.items.len() as f64));
}
if let Some(info) = &array.match_info {
match key {
"index" => return Some(JsValue::Number(info.index)),
"input" => return Some(JsValue::string(info.input.clone())),
"groups" => return Some(JsValue::Undefined),
_ => {}
}
}
array_index(key).and_then(|i| array.items.get(i as usize)).cloned()
}
JsValue::String(s) => {
if key == "length" {
return Some(JsValue::Number(s.encode_utf16().count() as f64));
}
let i = array_index(key)? as usize;
let unit = s.encode_utf16().nth(i)?;
Some(JsValue::string(String::from_utf16_lossy(&[unit])))
}
JsValue::Regex(re) => match key {
"lastIndex" => Some(JsValue::Number(re.last_index.get() as f64)),
"source" => Some(JsValue::string(re.source.clone())),
"flags" => {
let mut flags: Vec<char> = re.flags.chars().collect();
flags.sort_by_key(|f| "dgimsuvy".find(*f));
Some(JsValue::string(flags.into_iter().collect::<String>()))
}
_ => [
"hasIndices",
"global",
"ignoreCase",
"multiline",
"dotAll",
"unicode",
"unicodeSets",
"sticky",
]
.iter()
.position(|name| *name == key)
.map(|i| JsValue::Bool(re.flags.contains("dgimsuvy".as_bytes()[i] as char))),
},
JsValue::Doc(_)
| JsValue::Undefined
| JsValue::Null
| JsValue::Bool(_)
| JsValue::Number(_)
| JsValue::Function(_) => None,
}
}
/// `object[key]` in the safe evaluator: own properties, then the allowlisted
/// methods; any other inherited member is refused rather than guessed at.
pub fn get_property<'a>(object: &JsValue<'a>, key: &str) -> Result<JsValue<'a>, String> {
if matches!(object, JsValue::Undefined | JsValue::Null) {
return Err(format!(
"Cannot read properties of {} (reading '{key}')",
object.to_js_string()
));
}
if let Some(own) = own_property(object, key) {
return Ok(own);
}
if BLOCKED.contains(&key) {
return Err(format!(
"Cannot read properties of {} (reading '{key}')",
object.to_js_string()
));
}
let (methods, proto, kind): (&[(&str, Method)], &[&str], &str) = match object {
JsValue::String(_) => (STRING_METHODS, STRING_PROTO, "a string"),
JsValue::Doc(Json::Array(_)) | JsValue::Array(_) => {
(ARRAY_METHODS, ARRAY_PROTO, "an array")
}
JsValue::Number(_) => (NUMBER_METHODS, NUMBER_PROTO, "a number"),
JsValue::Regex(_) => (REGEX_METHODS, REGEX_PROTO, "a regular expression"),
JsValue::Bool(_) => (&[], BOOLEAN_PROTO, "a boolean"),
JsValue::Function(_) => (&[], FUNCTION_PROTO, "a function"),
JsValue::Doc(_) | JsValue::Undefined | JsValue::Null => (&[], &[], "an object"),
};
if let Some((_, method)) = methods.iter().find(|(name, _)| *name == key) {
return Ok(JsValue::Function(Rc::new(BoundMethod {
receiver: object.clone(),
method: *method,
})));
}
if proto.contains(&key) || OBJECT_PROTO.contains(&key) {
return Err(format!("{key} is not supported on {kind}"));
}
Ok(JsValue::Undefined)
}
fn utf16(s: &str) -> Vec<u16> {
s.encode_utf16().collect()
}
fn from_utf16(units: &[u16]) -> String {
String::from_utf16_lossy(units)
}
fn find_units(haystack: &[u16], needle: &[u16], from: usize) -> Option<usize> {
if needle.is_empty() {
return (from <= haystack.len()).then_some(from);
}
(from..=haystack.len().checked_sub(needle.len())?).find(|&i| haystack[i..].starts_with(needle))
}
fn clamp_index(n: f64, len: usize) -> usize {
n.clamp(0.0, len as f64) as usize
}
/// A relative start or end index, as `slice` and `includes` take them.
fn relative_index(n: f64, len: usize) -> usize {
if n < 0.0 { clamp_index(len as f64 + n, len) } else { clamp_index(n, len) }
}
fn arg<'v, 'a>(args: &'v [JsValue<'a>], i: usize) -> &'v JsValue<'a> {
args.get(i).unwrap_or(&JsValue::Undefined)
}
pub fn call_method<'a>(
bound: &BoundMethod<'a>,
args: &[JsValue<'a>],
) -> Result<JsValue<'a>, String> {
let receiver = &bound.receiver;
match bound.method {
Method::StringIncludes
| Method::StringStartsWith
| Method::StringEndsWith
| Method::StringIndexOf
| Method::StringLastIndexOf
| Method::StringToLowerCase
| Method::StringToUpperCase
| Method::StringTrim
| Method::StringTrimStart
| Method::StringTrimEnd
| Method::StringSlice
| Method::StringSubstring
| Method::StringSplit
| Method::StringMatch
| Method::StringToString => string_method(bound.method, &receiver.to_js_string(), args),
Method::ArrayIncludes
| Method::ArrayIndexOf
| Method::ArrayJoin
| Method::ArrayToString => {
let items: Vec<JsValue<'a>> = match receiver {
JsValue::Doc(Json::Array(items)) => items.iter().map(JsValue::from_doc).collect(),
JsValue::Array(array) => array.items.clone(),
_ => Vec::new(),
};
array_method(bound.method, &items, args)
}
Method::NumberToString => {
let n = receiver.to_number();
let radix = arg(args, 0);
let radix = if matches!(radix, JsValue::Undefined) { 10.0 } else { radix.to_integer() };
if !(2.0..=36.0).contains(&radix) {
return Err("toString() radix must be between 2 and 36".to_string());
}
Ok(JsValue::string(number_to_radix(n, radix as u32)))
}
Method::NumberToFixed => {
let digits = arg(args, 0).to_integer();
if !(0.0..=100.0).contains(&digits) {
return Err("toFixed() digits argument must be between 0 and 100".to_string());
}
Ok(JsValue::string(to_fixed(receiver.to_number(), digits as usize)))
}
Method::RegexTest => match receiver {
JsValue::Regex(re) => re.test(&arg(args, 0).to_js_string()).map(JsValue::Bool),
_ => Ok(JsValue::Bool(false)),
},
}
}
fn string_method<'a>(method: Method, s: &str, args: &[JsValue<'a>]) -> Result<JsValue<'a>, String> {
let units = utf16(s);
let len = units.len();
let search_string = |i: usize| -> Result<Vec<u16>, String> {
if let JsValue::Regex(_) = arg(args, i) {
return Err(format!(
"First argument to String.prototype.{} must not be a regular expression",
method.name()
));
}
Ok(utf16(&arg(args, i).to_js_string()))
};
Ok(match method {
Method::StringIncludes => {
let needle = search_string(0)?;
let from = clamp_index(arg(args, 1).to_integer(), len);
JsValue::Bool(find_units(&units, &needle, from).is_some())
}
Method::StringStartsWith => {
let needle = search_string(0)?;
let from = clamp_index(arg(args, 1).to_integer(), len);
JsValue::Bool(units[from..].starts_with(&needle))
}
Method::StringEndsWith => {
let needle = search_string(0)?;
let end = match arg(args, 1) {
JsValue::Undefined => len,
end => clamp_index(end.to_integer(), len),
};
JsValue::Bool(units[..end].ends_with(&needle))
}
Method::StringIndexOf => {
let needle = utf16(&arg(args, 0).to_js_string());
let from = clamp_index(arg(args, 1).to_integer(), len);
JsValue::Number(find_units(&units, &needle, from).map_or(-1.0, |i| i as f64))
}
Method::StringLastIndexOf => {
let needle = utf16(&arg(args, 0).to_js_string());
let position = arg(args, 1).to_number();
let start = if position.is_nan() { len } else { clamp_index(position.trunc(), len) };
let last = len.checked_sub(needle.len()).map(|max| start.min(max));
let found =
last.and_then(|last| (0..=last).rev().find(|&i| units[i..].starts_with(&needle)));
JsValue::Number(found.map_or(-1.0, |i| i as f64))
}
Method::StringToLowerCase => JsValue::string(s.to_lowercase()),
Method::StringToUpperCase => JsValue::string(s.to_uppercase()),
Method::StringTrim => JsValue::string(s.trim_matches(is_js_whitespace)),
Method::StringTrimStart => JsValue::string(s.trim_start_matches(is_js_whitespace)),
Method::StringTrimEnd => JsValue::string(s.trim_end_matches(is_js_whitespace)),
Method::StringSlice => {
let start = relative_index(arg(args, 0).to_integer(), len);
let end = match arg(args, 1) {
JsValue::Undefined => len,
end => relative_index(end.to_integer(), len),
};
JsValue::string(if start < end {
from_utf16(&units[start..end])
} else {
String::new()
})
}
Method::StringSubstring => {
let start = clamp_index(arg(args, 0).to_integer(), len);
let end = match arg(args, 1) {
JsValue::Undefined => len,
end => clamp_index(end.to_integer(), len),
};
JsValue::string(from_utf16(&units[start.min(end)..start.max(end)]))
}
Method::StringSplit => split(s, &units, arg(args, 0), arg(args, 1))?,
Method::StringMatch => {
let regex = match arg(args, 0) {
JsValue::Regex(re) => re.clone(),
JsValue::Undefined => Rc::new(JsRegex::new("(?:)", "")?),
other => Rc::new(JsRegex::new(&other.to_js_string(), "")?),
};
regex.match_string(s)?
}
Method::StringToString => JsValue::string(s),
Method::ArrayIncludes
| Method::ArrayIndexOf
| Method::ArrayJoin
| Method::ArrayToString
| Method::NumberToString
| Method::NumberToFixed
| Method::RegexTest => JsValue::Undefined,
})
}
fn split<'a>(
s: &str,
units: &[u16],
separator: &JsValue<'a>,
limit: &JsValue<'a>,
) -> Result<JsValue<'a>, String> {
let limit = match limit {
JsValue::Undefined => u32::MAX,
limit => limit.to_uint32(),
} as usize;
if let JsValue::Regex(re) = separator {
let parts = re.split(s, limit)?;
return Ok(JsValue::array(parts));
}
if limit == 0 {
return Ok(JsValue::array(Vec::new()));
}
if let JsValue::Undefined = separator {
return Ok(JsValue::array(vec![JsValue::string(s)]));
}
let separator = utf16(&separator.to_js_string());
let parts: Vec<JsValue<'a>> = if separator.is_empty() {
units.iter().map(|u| JsValue::string(from_utf16(&[*u]))).collect()
} else {
let mut parts = Vec::new();
let mut start = 0;
while let Some(i) = find_units(units, &separator, start) {
parts.push(JsValue::string(from_utf16(&units[start..i])));
start = i + separator.len();
}
parts.push(JsValue::string(from_utf16(&units[start..])));
parts
};
Ok(JsValue::array(parts.into_iter().take(limit).collect()))
}
fn array_method<'a>(
method: Method,
items: &[JsValue<'a>],
args: &[JsValue<'a>],
) -> Result<JsValue<'a>, String> {
let len = items.len();
Ok(match method {
Method::ArrayIncludes => {
let from = relative_index(arg(args, 1).to_integer(), len);
JsValue::Bool(items[from..].iter().any(|item| item.same_value_zero(arg(args, 0))))
}
Method::ArrayIndexOf => {
let from = relative_index(arg(args, 1).to_integer(), len);
let found = items[from..].iter().position(|item| item.strict_eq(arg(args, 0)));
JsValue::Number(found.map_or(-1.0, |i| (i + from) as f64))
}
Method::ArrayJoin => {
let separator = match arg(args, 0) {
JsValue::Undefined => ",".to_string(),
separator => separator.to_js_string(),
};
JsValue::string(join_values(items, &separator))
}
Method::ArrayToString => JsValue::string(join_values(items, ",")),
Method::StringIncludes
| Method::StringStartsWith
| Method::StringEndsWith
| Method::StringIndexOf
| Method::StringLastIndexOf
| Method::StringToLowerCase
| Method::StringToUpperCase
| Method::StringTrim
| Method::StringTrimStart
| Method::StringTrimEnd
| Method::StringSlice
| Method::StringSubstring
| Method::StringSplit
| Method::StringMatch
| Method::StringToString
| Method::NumberToString
| Method::NumberToFixed
| Method::RegexTest => JsValue::Undefined,
})
}
/// V8's `DoubleToRadixCString`, digit for digit.
fn number_to_radix(value: f64, radix: u32) -> String {
if radix == 10 || !value.is_finite() {
return number_to_string(value);
}
let digit_char = |d: u32| std::char::from_digit(d, radix).unwrap_or('0');
let next_double = |x: f64| f64::from_bits(x.to_bits() + 1);
let exponent = |x: f64| {
let biased = ((x.to_bits() >> 52) & 0x7FF) as i32;
if biased == 0 { -1074 } else { biased - 1075 }
};
let radix_f = f64::from(radix);
let negative = value < 0.0;
let value = value.abs();
let mut integer = value.floor();
let mut fraction = value - integer;
let mut delta = (0.5 * (next_double(value) - value)).max(next_double(0.0));
let mut fraction_digits: Vec<u32> = Vec::new();
let mut has_point = false;
if fraction >= delta {
has_point = true;
loop {
fraction *= radix_f;
delta *= radix_f;
let digit = fraction as u32;
fraction_digits.push(digit);
fraction -= f64::from(digit);
if (fraction > 0.5 || (fraction == 0.5 && digit & 1 == 1)) && fraction + delta > 1.0 {
loop {
match fraction_digits.pop() {
None => {
integer += 1.0;
break;
}
Some(d) if d + 1 < radix => {
fraction_digits.push(d + 1);
break;
}
Some(_) => {}
}
}
break;
}
if fraction < delta {
break;
}
}
}
let mut integer_digits: Vec<char> = Vec::new();
while exponent(integer / radix_f) > 0 {
integer /= radix_f;
integer_digits.push('0');
}
loop {
let remainder = integer % radix_f;
integer_digits.push(digit_char(remainder as u32));
integer = (integer - remainder) / radix_f;
if integer <= 0.0 {
break;
}
}
let mut out = String::new();
if negative {
out.push('-');
}
out.extend(integer_digits.iter().rev());
if has_point && !fraction_digits.is_empty() {
out.push('.');
out.extend(fraction_digits.iter().map(|d| digit_char(*d)));
}
out
}
/// `Number.prototype.toFixed`: exact decimal value, ties away from zero.
fn to_fixed(x: f64, digits: usize) -> String {
if !x.is_finite() || x.abs() >= 1e21 {
return number_to_string(x);
}
let exact = format!("{:.1100}", x.abs());
let (int_part, frac_part) = exact.split_once('.').unwrap_or((&exact, ""));
let mut kept: Vec<u8> = int_part.bytes().chain(frac_part.bytes().take(digits)).collect();
if frac_part.as_bytes().get(digits).is_some_and(|d| *d >= b'5') {
let mut i = kept.len();
loop {
if i == 0 {
kept.insert(0, b'1');
break;
}
i -= 1;
if kept[i] == b'9' {
kept[i] = b'0';
} else {
kept[i] += 1;
break;
}
}
}
let int_len = kept.len() - digits;
let mut out = String::from_utf8_lossy(&kept[..int_len]).into_owned();
if digits > 0 {
out.push('.');
out.push_str(&String::from_utf8_lossy(&kept[int_len..]));
}
if x < 0.0 { format!("-{out}") } else { out }
}
+57
View File
@@ -0,0 +1,57 @@
//! JSONPath (jsonpath-plus, with RFC 9535 additions) and XPath 1.0 filtering
//! of response text.
mod jsonpath;
mod xml;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
JsonPath,
XPath,
}
impl Language {
/// XML and HTML bodies take XPath; everything else is treated as JSON.
pub fn for_content_type(content_type: &str) -> Self {
let content_type = content_type.to_lowercase();
if content_type.contains("xml") || content_type.contains("html") {
Language::XPath
} else {
Language::JsonPath
}
}
}
/// The filtered text, or an empty string and the reason it could not be filtered.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilterResult {
pub content: String,
pub error: Option<String>,
}
impl FilterResult {
fn from_result(result: Result<String, String>) -> Self {
match result {
Ok(content) => FilterResult { content, error: None },
Err(error) => FilterResult { content: String::new(), error: Some(error) },
}
}
}
pub fn filter(language: Language, text: &str, query: &str) -> FilterResult {
match language {
Language::JsonPath => json_path(text, query),
Language::XPath => xpath(text, query),
}
}
/// Every match, as a pretty-printed JSON array.
pub fn json_path(text: &str, query: &str) -> FilterResult {
FilterResult::from_result(jsonpath::evaluate(text, query))
}
/// Matched nodes serialized as XML, one per line, or the string form of a
/// number, string, or boolean result.
pub fn xpath(text: &str, query: &str) -> FilterResult {
FilterResult::from_result(xml::evaluate(text, query))
}
+206
View File
@@ -0,0 +1,206 @@
use std::collections::BTreeMap;
use sxd_document::dom::{ChildOfElement, ChildOfRoot, Element, Text};
use sxd_document::parser;
use sxd_xpath::nodeset::Node;
use sxd_xpath::{Context, Factory, Value};
pub(crate) fn evaluate(text: &str, query: &str) -> Result<String, String> {
let xpath = Factory::new()
.build(query)
.map_err(|e| format!("Invalid filter: {e}"))?
.ok_or_else(|| "Invalid filter: empty expression".to_string())?;
// sxd only takes a DOCTYPE after some prolog token, and whitespace is one.
let text = if text.starts_with("<!DOCTYPE") { format!(" {text}") } else { text.to_string() };
let package = parser::parse(&text).map_err(|e| format!("Invalid XML: {e}"))?;
let document = package.as_document();
for child in document.root().children() {
if let ChildOfRoot::Element(e) = child {
merge_adjacent_text(e);
}
}
let value = xpath
.evaluate(&Context::new(), document.root())
.map_err(|e| format!("Invalid filter: {e}"))?;
Ok(match value {
Value::Nodeset(nodes) => {
nodes.document_order().into_iter().map(serialize_node).collect::<Vec<_>>().join("\n")
}
Value::String(s) => s,
Value::Boolean(b) => b.to_string(),
Value::Number(n) => crate::jsonpath::number_to_string(n),
})
}
/// The parser splits text at every entity and CDATA section; XPath sees one text node.
fn merge_adjacent_text(element: Element) {
let mut previous: Option<Text> = None;
for child in element.children() {
match child {
ChildOfElement::Text(text) => match previous {
Some(previous) => {
previous.set_text(&format!("{}{}", previous.text(), text.text()));
text.remove_from_parent();
}
None => previous = Some(text),
},
ChildOfElement::Element(e) => {
previous = None;
merge_adjacent_text(e);
}
ChildOfElement::Comment(_) | ChildOfElement::ProcessingInstruction(_) => {
previous = None
}
}
}
}
/// Namespace declarations already written, keyed by prefix (`None` is the default).
type Scope<'d> = BTreeMap<Option<&'d str>, &'d str>;
fn serialize_node(node: Node) -> String {
let mut out = String::new();
match node {
Node::Root(root) => {
for child in root.children() {
match child {
ChildOfRoot::Element(e) => write_element(&mut out, e, &Scope::new()),
ChildOfRoot::Comment(c) => write_comment(&mut out, c.text()),
ChildOfRoot::ProcessingInstruction(pi) => {
write_pi(&mut out, pi.target(), pi.value())
}
}
}
}
Node::Element(e) => write_element(&mut out, e, &Scope::new()),
Node::Attribute(a) => {
let name = node.prefixed_name().unwrap_or_else(|| a.name().local_part().to_string());
write_attribute(&mut out, &name, a.value());
}
Node::Text(t) => escape_into(&mut out, t.text(), false),
Node::Comment(c) => write_comment(&mut out, c.text()),
Node::ProcessingInstruction(pi) => write_pi(&mut out, pi.target(), pi.value()),
Node::Namespace(ns) => {
write_attribute(&mut out, &format!("xmlns:{}", ns.prefix()), ns.uri())
}
}
out
}
/// Elements declare the namespaces they use where they first need them, as xmldom does.
fn write_element<'d>(out: &mut String, element: Element<'d>, parent_scope: &Scope<'d>) {
let mut scope = parent_scope.clone();
let mut declarations = Vec::new();
let mut declare = |prefix: Option<&'d str>, uri: &'d str| {
if prefix != Some("xml") && scope.get(&prefix) != Some(&uri) {
scope.insert(prefix, uri);
declarations.push((prefix, uri));
}
};
let name = element.name();
let name = match name.namespace_uri() {
Some(uri) => match element.prefix_for_namespace_uri(uri, element.preferred_prefix()) {
Some(prefix) => {
declare(Some(prefix), uri);
format!("{prefix}:{}", name.local_part())
}
None => {
declare(None, uri);
name.local_part().to_string()
}
},
None => {
if parent_scope.get(&None).is_some_and(|uri| !uri.is_empty()) {
declare(None, "");
}
name.local_part().to_string()
}
};
let mut attributes = Vec::new();
for attribute in element.attributes() {
let attr_name = attribute.name();
let prefix = attr_name.namespace_uri().and_then(|uri| {
Some((element.prefix_for_namespace_uri(uri, attribute.preferred_prefix())?, uri))
});
let attr_name = match prefix {
Some((prefix, uri)) => {
declare(Some(prefix), uri);
format!("{prefix}:{}", attr_name.local_part())
}
None => attr_name.local_part().to_string(),
};
attributes.push((attr_name, attribute.value()));
}
out.push('<');
out.push_str(&name);
for (attr_name, value) in attributes {
out.push(' ');
write_attribute(out, &attr_name, value);
}
for (prefix, uri) in declarations {
out.push(' ');
match prefix {
Some(prefix) => write_attribute(out, &format!("xmlns:{prefix}"), uri),
None => write_attribute(out, "xmlns", uri),
}
}
let children = element.children();
if children.is_empty() {
out.push_str("/>");
return;
}
out.push('>');
for child in children {
match child {
ChildOfElement::Element(e) => write_element(out, e, &scope),
ChildOfElement::Text(t) => escape_into(out, t.text(), false),
ChildOfElement::Comment(c) => write_comment(out, c.text()),
ChildOfElement::ProcessingInstruction(pi) => write_pi(out, pi.target(), pi.value()),
}
}
out.push_str("</");
out.push_str(&name);
out.push('>');
}
fn write_attribute(out: &mut String, name: &str, value: &str) {
out.push_str(name);
out.push_str("=\"");
escape_into(out, value, true);
out.push('"');
}
fn write_comment(out: &mut String, text: &str) {
out.push_str("<!--");
out.push_str(text);
out.push_str("-->");
}
fn write_pi(out: &mut String, target: &str, value: Option<&str>) {
out.push_str("<?");
out.push_str(target);
if let Some(value) = value {
out.push(' ');
out.push_str(value);
}
out.push_str("?>");
}
fn escape_into(out: &mut String, text: &str, attribute: bool) {
for c in text.chars() {
match c {
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
'"' if attribute => out.push_str("&quot;"),
'\t' if attribute => out.push_str("&#9;"),
'\n' if attribute => out.push_str("&#10;"),
'\r' if attribute => out.push_str("&#13;"),
c => out.push(c),
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+300
View File
@@ -0,0 +1,300 @@
//! Ported from the `@yaak/filter-jsonpath` plugin's tests.
use serde_json::{Value, json};
use yaak_filter::{FilterResult, Language, filter, json_path};
fn matches(json: &Value, path: &str) -> Value {
let result = json_path(&json.to_string(), path);
assert_eq!(result.error, None, "{path}");
serde_json::from_str(&result.content).unwrap()
}
/// The path the response breadcrumb builds for a key (`segmentsToJsonPath` in the client).
fn crumb(key: &str) -> String {
let bare = key != "$"
&& key.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
if bare { format!(".{key}") } else { format!("[{}]", serde_json::to_string(key).unwrap()) }
}
#[test]
fn breadcrumb_selects_the_literal_key_not_an_operator_or_expression() {
let keys = [
"identifier",
"a.b",
"space key",
"",
"quote\"key",
"single'key",
"back\\slash",
"bracket]key",
"combined\"\\]'key",
"*",
"..",
"$",
"$foo",
"^",
"~",
"a,b",
"0:2",
"?(true)",
"(@.length-1)",
"@number()",
"`key",
"x)]y",
"x)']y",
"semi;colon",
"percent%@%key",
"line\nbreak\t\u{0}",
"emoji 🤔",
"__proto__",
"constructor",
];
for key in keys {
let mut object = serde_json::Map::new();
object.insert(key.to_string(), json!("needle-value"));
object.insert("decoy".to_string(), json!("wrong-value"));
let path = format!("${}", crumb(key));
assert_eq!(matches(&Value::Object(object), &path), json!(["needle-value"]), "{key:?}");
}
}
#[test]
fn nested_unusual_keys_and_arrays_filter_at_every_crumb() {
let inner = json!({ "inner'\\]": { "": "needle-value" } });
let json = json!({ "outer": { "quote\"key": ["decoy", inner] } });
let crumbs = [
crumb("outer"),
crumb("quote\"key"),
"[1]".to_string(),
crumb("inner'\\]"),
crumb(""),
];
let path = |count: usize| format!("${}", crumbs[..count].concat());
assert_eq!(matches(&json, &path(5)), json!(["needle-value"]));
assert_eq!(matches(&json, &path(4)), json!([{ "": "needle-value" }]));
assert_eq!(matches(&json, &path(3)), json!([inner]));
assert_eq!(matches(&json, &path(2)), json!([["decoy", inner]]));
assert_eq!(matches(&json, &path(1)), json!([json["outer"]]));
assert_eq!(matches(&json, &path(0)), json!([json]));
}
#[test]
fn root_arrays_and_quoted_numeric_object_keys_remain_distinct() {
let json = json!(["decoy", { "01": { "1": "needle-value" } }]);
assert_eq!(matches(&json, r#"$[1]["01"]["1"]"#), json!(["needle-value"]));
assert_eq!(matches(&json, r#"$[1]["01"]["missing"]"#), json!([]));
}
#[test]
fn retains_the_matched_value() {
for value in [
json!(null),
json!(false),
json!(0),
json!(""),
json!([1, 2]),
json!({ "value": 3 }),
] {
let json = json!({ "quote\"key": value });
assert_eq!(matches(&json, r#"$["quote\"key"]"#), json!([value]));
}
}
#[test]
fn only_follows_own_properties_while_allowing_prototype_named_keys() {
let json: Value =
serde_json::from_str(r#"{"a.b":{"__proto__":{"constructor":"own-value"}}}"#).unwrap();
assert_eq!(matches(&json, r#"$["a.b"].__proto__.constructor"#), json!(["own-value"]));
assert_eq!(matches(&json, r#"$["a.b"].constructor"#), json!([]));
assert_eq!(matches(&json, r#"$["a.b"].__proto__.toString"#), json!([]));
assert_eq!(matches(&json, r#"$["a.b"].__proto__.constructor.__proto__"#), json!([]));
}
#[test]
fn existing_jsonpath_expressions() {
let json = json!({ "items": [{ "id": 1 }, { "id": 2 }, { "id": 3 }], "space key": 4 });
let cases = [
("$", json!([json])),
("$.items[1].id", json!([2])),
(r#"$["space key"]"#, json!([4])),
("$['space key']", json!([4])),
("$.items[*].id", json!([1, 2, 3])),
("$..id", json!([1, 2, 3])),
("$.items[0:2].id", json!([1, 2])),
("$.items[0,2].id", json!([1, 3])),
("$.items[-1].id", json!([3])),
("$.items[?(@.id > 1)].id", json!([2, 3])),
("$.items[?@.id > 1].id", json!([2, 3])),
(r#"$.items[?(@["id"] > 1)].id"#, json!([2, 3])),
(r#"$["items"][*].id"#, json!([1, 2, 3])),
("$.items[?length(@) == 1].id", json!([1, 2, 3])),
("$.missing", json!([])),
];
for (path, expected) in cases {
assert_eq!(matches(&json, path), expected, "{path}");
}
}
fn error(json: &Value, path: &str) -> String {
let result = json_path(&json.to_string(), path);
assert_eq!(result.content, "", "{path}");
let error = result.error.unwrap_or_else(|| panic!("{path} should fail"));
assert!(error.starts_with("Invalid filter: "), "{path}: {error}");
error
}
#[test]
fn jsonpath_plus_extensions_work() {
let json = json!({ "items": [{ "id": 1 }, { "id": 2 }, { "id": 3 }], "space key": 4 });
let cases = [
("$.items[(@.length-1)].id", json!([3])),
(r#"$["items"].$"#, json!([json["items"]])),
("$.items[?(@.id.toString() == '1')]", json!([{ "id": 1 }])),
("$.items[?(@property == 1)].id", json!([2])),
("$.items[?(@.id > 1)]^", json!([json["items"], json["items"]])),
("$.items[*]~", json!([0, 1, 2])),
("$..*@number()", json!([4, 1, 2, 3])),
("$.items[?(@.id == '2')].id", json!([2])),
("$.items[?(/^[13]$/.test(@.id))].id", json!([1, 3])),
("$.`space key", json!([4])),
];
for (path, expected) in cases {
assert_eq!(matches(&json, path), expected, "{path}");
}
}
#[test]
fn calls_outside_the_allowlist_and_statements_are_errors() {
let json = json!({ "items": [{ "id": 1, "name": "a" }], "none": [] });
for (path, named) in [
("$.items[?(@.name.padStart(2) == ' a')]", "padStart"),
("$.items[?(@.name['padStart'](2) == ' a')]", "padStart"),
("$.items[?(@.hasOwnProperty('id'))]", "hasOwnProperty"),
("$.items[?(@.id; true)]", "multiple expressions"),
("$.items[?(var x = 1; x)]", "var"),
("$.items[?(@.id = 2)]", "assignment"),
("$.items[?(@.id++)]", "++"),
("$.items[?(@.id ** 2)]", "**"),
("$.items[?(foo(@.id))]", "foo()"),
] {
let message = error(&json, path);
assert!(message.contains(named), "{path}: {message}");
}
}
#[test]
fn deviation_eager_parse_rejects_filters_no_node_reaches() {
let json = json!({ "none": [] });
assert!(error(&json, "$.none[?(@.a ==)]").contains("Expected expression"));
assert!(error(&json, "$.none[?(@.a.padStart(1))]").contains("padStart"));
}
#[test]
fn deviation_rfc_negative_index_and_step() {
let json = json!({ "a": [0, 1, 2, 3] });
assert_eq!(matches(&json, "$.a[-1]"), json!([3]));
assert_eq!(matches(&json, "$.a[-5]"), json!([]));
assert_eq!(matches(&json, "$.a[::-1]"), json!([3, 2, 1, 0]));
assert_eq!(matches(&json, "$.a[3:0:-2]"), json!([3, 1]));
// Parts of a union keep jsonpath-plus's reading, so its non-empty results stand.
assert_eq!(matches(&json, "$.a[0,-1]"), json!([0]));
}
#[test]
fn deviation_rfc_filter_without_parentheses() {
let json = json!({ "a": [{ "x": 1 }, { "x": 0 }, { "y": 2 }] });
assert_eq!(matches(&json, "$.a[?@.x]"), json!([{ "x": 1 }, { "x": 0 }]));
assert_eq!(matches(&json, "$.a[?@.x == '1']"), json!([]));
assert_eq!(matches(&json, "$.a[?@.x < 1 || @.y]"), json!([{ "x": 0 }, { "y": 2 }]));
assert!(error(&json, "$.a[?@.* == 1]").contains("cannot be compared"));
}
#[test]
fn deviation_rfc_functions() {
let json = json!({ "a": [{ "n": "ab", "t": [1, 2] }, { "n": "b", "t": [] }] });
assert_eq!(matches(&json, "$.a[?length(@.n) == 2].n"), json!(["ab"]));
assert_eq!(matches(&json, "$.a[?(count(@.t[*]) == 0)].n"), json!(["b"]));
assert_eq!(matches(&json, "$.a[?match(@.n, 'a.')].n"), json!(["ab"]));
assert_eq!(matches(&json, "$.a[?(search(@.n, 'b'))].n"), json!(["ab", "b"]));
assert_eq!(matches(&json, "$.a[?value(@.t[0]) == 1].n"), json!(["ab"]));
assert!(error(&json, "$.a[?length(@.n)]").contains("compare it"));
}
#[test]
fn deviation_falsy_root_is_a_document() {
for root in ["0", "\"\"", "false", "null"] {
assert_eq!(json_path(root, "$").content, format!("[\n {root}\n]"), "{root}");
}
}
#[test]
fn deviation_lone_surrogate_becomes_replacement_character() {
assert_eq!(matches(&json!({ "s": "🤔" }), "$.s[0]"), json!(["\u{fffd}"]));
}
#[test]
fn integer_like_keys_come_first_as_in_javascript() {
let result = json_path(r#"{"b":1,"10":2,"a":3,"2":4}"#, "$.*~");
assert_eq!(result.content, "[\n \"2\",\n \"10\",\n \"b\",\n \"a\"\n]");
}
#[test]
fn output_is_pretty_printed_and_keeps_key_order() {
let result = json_path(r#"{"b":{"z":1,"a":[true,null]}}"#, "$.b");
assert_eq!(
result,
FilterResult {
content: "[\n {\n \"z\": 1,\n \"a\": [\n true,\n null\n ]\n }\n]"
.to_string(),
error: None,
}
);
}
#[test]
fn invalid_json_is_an_error() {
let result = json_path("{not json", "$");
assert_eq!(result.content, "");
assert!(result.error.unwrap().starts_with("Invalid JSON: "));
}
#[test]
fn content_type_picks_the_language() {
assert_eq!(Language::for_content_type("application/json"), Language::JsonPath);
assert_eq!(Language::for_content_type("text/plain"), Language::JsonPath);
assert_eq!(Language::for_content_type(""), Language::JsonPath);
assert_eq!(Language::for_content_type("application/xml"), Language::XPath);
assert_eq!(Language::for_content_type("application/rss+XML"), Language::XPath);
assert_eq!(Language::for_content_type("text/html; charset=utf-8"), Language::XPath);
assert_eq!(filter(Language::JsonPath, "[1]", "$[0]").content, "[\n 1\n]");
}
#[test]
fn comparing_an_array_to_an_object_is_false() {
let result = json_path(r#"[{"a":[],"b":{}},{"a":[1],"b":[1]}]"#, "$[?@.a == @.b].a");
assert_eq!(result.content, "[\n [\n 1\n ]\n]");
}
#[test]
fn invalid_escape_before_a_multibyte_character_is_an_error() {
let result = json_path("{\"a\":\"\\é\"}", "$");
assert!(result.error.unwrap().starts_with("Invalid JSON: "));
}
#[test]
fn deeply_nested_filters_are_errors() {
let nested = |open: &str, inner: &str, close: &str| {
format!("{}{inner}{}", open.repeat(5000), close.repeat(5000))
};
for query in [
format!("$[?{} == 1]", nested("length(", "@", ")")),
format!("$[?{}]", nested("(", "@", ")")),
format!("$[?({} == 1)]", nested("(", "@", ")")),
nested("$[?@", "", "]"),
] {
let result = json_path("[[1]]", &query);
assert!(result.error.unwrap().starts_with("Invalid filter: "), "{}", &query[..20]);
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Every case in `fixtures/jsonpath_plus.json`, which
//! `fixtures/generate-jsonpath-plus.mjs` records from jsonpath-plus 11.0.0 as
//! the removed plugin ran it.
use serde_json::Value;
use std::collections::BTreeMap;
use yaak_filter::json_path;
/// Where this engine means to differ from jsonpath-plus; the fixture holds the
/// result each such case should give instead.
const DEVIATIONS: &[&str] = &[
// `["a.b"]` and `['a.b']` are literal keys, never operators or expressions.
"literal-key",
// RFC 9535 `[-1]`, which jsonpath-plus looks up as the key "-1".
"rfc-negative-index",
// RFC 9535 `[::-1]`, which jsonpath-plus does not read as a slice.
"rfc-negative-step",
// RFC 9535 `[?@.a > 1]`, which jsonpath-plus looks up as a key.
"rfc-filter",
// RFC 9535 `length()`, `count()`, `match()`, `search()` and `value()` in `?(...)`.
"rfc-function",
// A call outside the allowlist, or several statements: an error, not JavaScript.
"unsupported",
// Filters are compiled up front, so a bad one fails even when no node reaches it.
"eager-parse",
// jsonpath-plus returns undefined for a root of 0, "", false or null.
"falsy-root",
// Indexing into a string can split a surrogate pair, which becomes U+FFFD here.
"lone-surrogate",
];
#[test]
fn matches_jsonpath_plus_except_named_deviations() {
let fixture: Value = serde_json::from_str(include_str!("fixtures/jsonpath_plus.json")).unwrap();
let docs = fixture["docs"].as_object().unwrap();
let cases = fixture["cases"].as_array().unwrap();
assert!(cases.len() >= 300, "{} cases", cases.len());
let mut failures = Vec::new();
let mut per_deviation: BTreeMap<&str, usize> = BTreeMap::new();
for case in cases {
let doc = docs[case["doc"].as_str().unwrap()].as_str().unwrap();
let path = case["path"].as_str().unwrap();
let expected = &case["expected"];
let label = match case["deviation"].as_str() {
Some(deviation) => {
assert!(
DEVIATIONS.contains(&deviation),
"unknown deviation {deviation} for {path}"
);
*per_deviation.entry(deviation).or_default() += 1;
format!("{} {path} [{deviation}]", case["doc"])
}
None => format!("{} {path}", case["doc"]),
};
let result = json_path(doc, path);
let ok = if expected["error"] == Value::Bool(true) {
result.content.is_empty()
&& result.error.as_ref().is_some_and(|e| e.starts_with("Invalid filter: "))
} else {
result.error.is_none() && Some(result.content.as_str()) == expected["content"].as_str()
};
if !ok {
failures.push(format!("{label}\n expected: {expected}\n got: {result:?}"));
}
}
for deviation in DEVIATIONS {
assert!(per_deviation.contains_key(deviation), "no case covers {deviation}");
}
assert!(
failures.is_empty(),
"{} of {} cases differ:\n{}",
failures.len(),
cases.len(),
failures.join("\n")
);
}
+105
View File
@@ -0,0 +1,105 @@
//! Expected output is what the `@yaak/filter-xpath` plugin (xmldom and xpath)
//! returned for the same input, except where a test says otherwise.
use yaak_filter::{Language, filter, xpath};
const ITEMS: &str = r#"<root><item id="1">A</item><item id="2">B &amp; C</item><empty/></root>"#;
const FEED: &str = r#"<feed xmlns="http://www.w3.org/2005/Atom" xmlns:m="urn:m"><entry><title>T</title><m:thumb url="x"/></entry></feed>"#;
const MIXED: &str =
r#"<?xml version="1.0"?><!-- c --><a x="1&quot;2"><!--note--><b><![CDATA[<raw>]]></b></a>"#;
fn content(xml: &str, query: &str) -> String {
let result = xpath(xml, query);
assert_eq!(result.error, None, "{query}");
result.content
}
#[test]
fn node_sets_serialize_one_node_per_line() {
assert_eq!(
content(ITEMS, "//item"),
"<item id=\"1\">A</item>\n<item id=\"2\">B &amp; C</item>"
);
assert_eq!(content(ITEMS, "//item/text()"), "A\nB &amp; C");
assert_eq!(content(ITEMS, "//empty"), "<empty/>");
assert_eq!(content(ITEMS, "//missing"), "");
assert_eq!(content(ITEMS, "//item[@id='2']/.."), ITEMS);
assert_eq!(content(MIXED, "//comment()"), "<!-- c -->\n<!--note-->");
}
/// The plugin printed a leading space before each attribute (` id="1"`).
#[test]
fn attributes_serialize_as_name_and_value() {
assert_eq!(content(ITEMS, "//item/@id"), "id=\"1\"\nid=\"2\"");
assert_eq!(content(MIXED, "/a/@x"), "x=\"1&quot;2\"");
}
#[test]
fn scalar_results_print_like_javascript() {
assert_eq!(content(ITEMS, "count(//item)"), "2");
assert_eq!(content(ITEMS, "string(//item[2])"), "B & C");
assert_eq!(content(ITEMS, "sum(//item/@id) div 4"), "0.75");
assert_eq!(content(ITEMS, "boolean(//empty)"), "true");
assert_eq!(content(ITEMS, "1 div 0"), "Infinity");
assert_eq!(content(ITEMS, "0 div 0"), "NaN");
assert_eq!(content(ITEMS, "-0"), "0");
}
#[test]
fn namespaces_are_declared_where_they_are_used() {
assert_eq!(
content(FEED, "//*[local-name()='entry']"),
r#"<entry xmlns="http://www.w3.org/2005/Atom"><title>T</title><m:thumb url="x" xmlns:m="urn:m"/></entry>"#
);
assert_eq!(content(FEED, "//*[local-name()='thumb']"), r#"<m:thumb url="x" xmlns:m="urn:m"/>"#);
// XPath 1.0 has no default namespace for unprefixed names.
assert_eq!(content(FEED, "//entry"), "");
}
/// The plugin kept the XML declaration and the CDATA section; this parser keeps neither.
#[test]
fn root_serializes_the_whole_document() {
assert_eq!(
content(MIXED, "/"),
r#"<!-- c --><a x="1&quot;2"><!--note--><b>&lt;raw&gt;</b></a>"#
);
assert_eq!(content(MIXED, "//b/text()"), "&lt;raw&gt;");
assert_eq!(content("<a>x<![CDATA[<y>]]>&amp;z</a>", "/a/text()"), "x&lt;y&gt;&amp;z");
}
#[test]
fn doctype_is_accepted() {
assert_eq!(content("<!DOCTYPE note><note>hi</note>", "/note"), "<note>hi</note>");
assert_eq!(content("<?xml version=\"1.0\"?><!DOCTYPE note><note/>", "/note"), "<note/>");
}
#[test]
fn invalid_queries_and_documents_are_errors() {
for (xml, query, prefix) in [
(ITEMS, "//item[", "Invalid filter: "),
(ITEMS, "", "Invalid filter: "),
(ITEMS, "unknown-fn()", "Invalid filter: "),
("<html><body><p>one<br></p></body></html>", "//p", "Invalid XML: "),
("<a><b>", "/a", "Invalid XML: "),
] {
let result = xpath(xml, query);
assert_eq!(result.content, "", "{query}");
assert!(
result.error.as_deref().unwrap_or_default().starts_with(prefix),
"{query}: {result:?}"
);
}
}
#[test]
fn filter_dispatches_to_xpath() {
assert_eq!(filter(Language::XPath, ITEMS, "count(//item)").content, "2");
}
#[test]
fn numbers_switch_to_exponents_where_javascript_does() {
assert_eq!(content("<a>0.0000001</a>", "number(/a)"), "1e-7");
assert_eq!(content("<a>0.000001</a>", "number(/a)"), "0.000001");
assert_eq!(content("<a>1000000000000000000000</a>", "number(/a)"), "1e+21");
assert_eq!(content("<a>100000000000000000000</a>", "number(/a)"), "100000000000000000000");
}
+8 -1
View File
@@ -84,10 +84,17 @@ export type FileFilter = { name: string,
*/
extensions: Array<string>, };
export type FilterLanguage = "jsonpath" | "xpath";
export type FilterRequest = { content: string, filter: string, };
export type FilterResponse = { content: string, error?: string, };
/**
* A plugin asking the host to run a JSONPath or XPath query over some text.
*/
export type FilterTextRequest = { language: FilterLanguage, text: string, query: string, };
export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
@@ -498,7 +505,7 @@ sourceKeys?: { [key in string]?: string }, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "window_info_response" } & WindowInfoResponse | { "type": "list_open_workspaces_request" } & ListOpenWorkspacesRequeLine truncated
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "filter_text_request" } & FilterTextRequest | { "type": "filter_text_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "windoLine truncated
export type JsonPrimitive = string | number | boolean | null;
+21
View File
@@ -69,6 +69,8 @@ pub enum InternalEventPayload {
FilterRequest(FilterRequest),
FilterResponse(FilterResponse),
FilterTextRequest(FilterTextRequest),
FilterTextResponse(FilterResponse),
ExportHttpRequestRequest(ExportHttpRequestRequest),
ExportHttpRequestResponse(ExportHttpRequestResponse),
@@ -286,9 +288,28 @@ pub struct FilterRequest {
pub struct FilterResponse {
pub content: String,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// A plugin asking the host to run a JSONPath or XPath query over some text.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct FilterTextRequest {
pub language: FilterLanguage,
pub text: String,
pub query: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "gen_events.ts")]
pub enum FilterLanguage {
JsonPath,
XPath,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
+12 -50
View File
@@ -9,14 +9,13 @@ use crate::events::{
CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest,
CallTemplateFunctionArgs, CallTemplateFunctionRequest, CallTemplateFunctionResponse,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, EmptyPayload, ErrorResponse,
FilterRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportRequest, ImportResponse,
InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose,
ShowToastRequest,
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigRequest,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesRequest,
GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
ImportRequest, ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive,
PluginContext, RenderPurpose, ShowToastRequest,
};
use crate::native_template_functions::{template_function_keyring, template_function_secure};
use crate::nodejs::start_nodejs_plugin_runtime;
@@ -1139,46 +1138,6 @@ impl PluginManager {
Some(resp) => Ok(resp),
}
}
pub async fn filter_data(
&self,
plugin_context: &PluginContext,
filter: &str,
content: &str,
content_type: &str,
) -> Result<FilterResponse> {
let ct = content_type.to_lowercase();
let plugin_name = if ct.contains("xml") || ct.contains("html") {
"@yaak/filter-xpath"
} else {
"@yaak/filter-jsonpath"
};
let plugin = self
.get_plugin_by_name(plugin_name)
.await
.ok_or(PluginNotFoundErr(plugin_name.to_string()))?;
let event = self
.send_to_plugin_and_wait(
plugin_context,
&plugin,
&InternalEventPayload::FilterRequest(FilterRequest {
filter: filter.to_string(),
content: content.to_string(),
}),
Duration::from_secs(5),
)
.await?;
match event.payload {
InternalEventPayload::FilterResponse(resp) => Ok(resp),
InternalEventPayload::EmptyResponse(_) => {
Err(PluginErr("Filter returned empty".to_string()))
}
e => Err(PluginErr(format!("Export returned invalid event {:?}", e))),
}
}
}
fn source_priority(source: &PluginSource) -> i32 {
@@ -1207,8 +1166,9 @@ fn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {
/// whose directory isn't in this list are filtered out by `resolve_plugins_for_runtime`.
///
/// `exporter-curl` was renamed to `action-copy-curl` in 19ffcd18, which is why affected
/// installs show "Copy as cURL" twice.
const REMOVED_BUNDLED_PLUGIN_DIRS: &[&str] = &["exporter-curl"];
/// installs show "Copy as cURL" twice. `filter-jsonpath` and `filter-xpath` moved into
/// the `yaak-filter` crate.
const REMOVED_BUNDLED_PLUGIN_DIRS: &[&str] = &["exporter-curl", "filter-jsonpath", "filter-xpath"];
/// Whether a plugin directory path is one of the known-removed bundled plugins.
fn is_removed_bundled_plugin_dir(dir: &str) -> bool {
@@ -1256,6 +1216,8 @@ mod tests {
r"C:\Users\me\AppData\Local\Yaak\vendored\plugins\exporter-curl"
));
assert!(is_removed_bundled_plugin_dir("vendored/plugins/exporter-curl/"));
assert!(is_removed_bundled_plugin_dir("vendored/plugins/filter-jsonpath"));
assert!(is_removed_bundled_plugin_dir("vendored/plugins/filter-xpath"));
}
#[test]
+1
View File
@@ -17,6 +17,7 @@ tokio = { workspace = true, features = ["sync", "rt"] }
yaak-http = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-filter = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
+59 -10
View File
@@ -7,16 +7,16 @@ use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
UpsertModelResponse, WindowInfoRequest,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FilterLanguage, FilterResponse,
FilterTextRequest, FindHttpResponsesRequest, FindHttpResponsesResponse, GetCookieValueRequest,
GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest,
GetHttpResponseBodyInfoResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
PromptFormRequest, PromptTextRequest, ReadHttpResponseBodyChunkRequest,
ReadHttpResponseBodyChunkResponse, ReloadResponse, RenderGrpcRequestRequest,
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
@@ -50,6 +50,7 @@ pub enum SharedRequest<'a> {
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
FilterText(&'a FilterTextRequest),
}
#[derive(Debug)]
@@ -156,6 +157,9 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
InternalEventPayload::DeleteModelRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
}
InternalEventPayload::FilterTextRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::FilterText(req))
}
InternalEventPayload::RenderGrpcRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
}
@@ -485,6 +489,17 @@ fn build_shared_reply(
InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model })
}
SharedRequest::FilterText(req) => {
let language = match req.language {
FilterLanguage::JsonPath => yaak_filter::Language::JsonPath,
FilterLanguage::XPath => yaak_filter::Language::XPath,
};
let result = yaak_filter::filter(language, &req.text, &req.query);
InternalEventPayload::FilterTextResponse(FilterResponse {
content: result.content,
error: result.error,
})
}
}
}
@@ -638,6 +653,40 @@ mod tests {
}
}
#[test]
fn filter_text_is_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
let cases = [
(FilterLanguage::JsonPath, r#"{"a":[1,2]}"#, "$.a[1]", "[\n 2\n]", None),
(FilterLanguage::XPath, "<a><b>x</b></a>", "//b", "<b>x</b>", None),
(FilterLanguage::JsonPath, "{}", "$[?(@ ==)]", "", Some("Invalid filter: ")),
];
for (language, text, query, content, error) in cases {
let payload = InternalEventPayload::FilterTextRequest(FilterTextRequest {
language,
text: text.to_string(),
query: query.to_string(),
});
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::FilterTextResponse(
resp,
))) => {
assert_eq!(resp.content, content, "{query}");
assert_eq!(resp.error.is_some(), error.is_some(), "{query}");
if let (Some(actual), Some(prefix)) = (resp.error, error) {
assert!(actual.starts_with(prefix), "{actual}");
}
}
other => panic!("unexpected filter result: {other:?}"),
}
}
}
/// A store that answers from memory, standing in for whatever holds the
/// bytes — the point being that the dispatch below never learns which.
struct FakeBodyStore {
-29
View File
@@ -31,8 +31,6 @@
"plugins/auth-ntlm",
"plugins/auth-oauth2",
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-bruno",
"plugins/importer-curl",
"plugins/importer-insomnia",
@@ -5183,14 +5181,6 @@
"resolved": "plugins-external/faker",
"link": true
},
"node_modules/@yaak/filter-jsonpath": {
"resolved": "plugins/filter-jsonpath",
"link": true
},
"node_modules/@yaak/filter-xpath": {
"resolved": "plugins/filter-xpath",
"link": true
},
"node_modules/@yaak/httpsnippet": {
"resolved": "plugins-external/httpsnippet",
"link": true
@@ -15188,25 +15178,6 @@
"@types/jsonwebtoken": "^9.0.7"
}
},
"plugins/filter-jsonpath": {
"name": "@yaak/filter-jsonpath",
"version": "0.1.0",
"dependencies": {
"@yaakapp-internal/lib": "^1.0.0",
"jsonpath-plus": "^11.0.0"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
}
},
"plugins/filter-xpath": {
"name": "@yaak/filter-xpath",
"version": "0.1.0",
"dependencies": {
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
},
"plugins/importer-bruno": {
"name": "@yaak/importer-bruno",
"version": "0.1.0",
-2
View File
@@ -30,8 +30,6 @@
"plugins/auth-ntlm",
"plugins/auth-oauth2",
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-bruno",
"plugins/importer-curl",
"plugins/importer-insomnia",
+1 -1
View File
@@ -199,7 +199,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
if (str(payload, "filter") != null) {
return {
content: "",
error: "Response filters come from a plugin, which this host doesn't run yet",
error: "Response filters are not available in the browser yet",
};
}
+8 -1
View File
@@ -84,10 +84,17 @@ export type FileFilter = { name: string,
*/
extensions: Array<string>, };
export type FilterLanguage = "jsonpath" | "xpath";
export type FilterRequest = { content: string, filter: string, };
export type FilterResponse = { content: string, error?: string, };
/**
* A plugin asking the host to run a JSONPath or XPath query over some text.
*/
export type FilterTextRequest = { language: FilterLanguage, text: string, query: string, };
export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
@@ -498,7 +505,7 @@ sourceKeys?: { [key in string]?: string }, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "window_info_response" } & WindowInfoResponse | { "type": "list_open_workspaces_request" } & ListOpenWorkspacesRequeLine truncated
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "filter_text_request" } & FilterTextRequest | { "type": "filter_text_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "windoLine truncated
export type JsonPrimitive = string | number | boolean | null;
@@ -1,4 +1,5 @@
import type {
FilterResponse,
FindHttpResponsesRequest,
FindHttpResponsesResponse,
FormInput,
@@ -226,6 +227,16 @@ export interface Context {
templates: {
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
};
/**
* Query text the way the response filter box does. A query or document
* that can't be read comes back as `error`, with empty `content`.
*/
filter: {
/** RFC 9535 JSONPath. Every match, as a pretty-printed JSON array. */
jsonPath(text: string, query: string): Promise<FilterResponse>;
/** XPath 1.0. Matched nodes as XML, one per line, or a scalar result as text. */
xPath(text: string, query: string): Promise<FilterResponse>;
};
plugin: {
reload(): void;
};
@@ -15,6 +15,8 @@ import type {
BootRequest,
DeleteKeyValueResponse,
DeleteModelResponse,
FilterLanguage,
FilterResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
@@ -657,6 +659,14 @@ export class PluginInstance {
);
};
const filterText = (language: FilterLanguage, text: string, query: string) =>
this.#sendForReply<FilterResponse>(context, {
type: "filter_text_request",
language,
text,
query,
});
const _windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
@@ -1003,6 +1013,10 @@ export class PluginInstance {
return result.data as any;
},
},
filter: {
jsonPath: (text: string, query: string) => filterText("jsonpath", text, query),
xPath: (text: string, query: string) => filterText("xpath", text, query),
},
store: {
get: async <T>(key: string) => {
const payload = { type: "get_key_value_request", key } as const;
@@ -0,0 +1,71 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { InternalEvent, InternalEventPayload } from "@yaakapp/api";
import { expect, test } from "vite-plus/test";
import { EventChannel } from "../src/EventChannel";
import { PluginInstance } from "../src/PluginInstance";
test("ctx.filter asks the host and hands back its reply", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "yaak-filter-"));
await mkdir(path.join(dir, "build"));
await writeFile(
path.join(dir, "build/index.js"),
`exports.plugin = { async init(ctx) {
const results = [
await ctx.filter.jsonPath('{"a":1}', "$.a"),
await ctx.filter.xPath("<a/>", "//b["),
];
await ctx.toast.show({ message: JSON.stringify(results) });
} };`,
);
const channel = new EventChannel();
const context = { workspaceId: null, label: null };
const bootRequest = { dir, watch: false };
const instance = new PluginInstance({ bootRequest, pluginRefId: "test", context }, channel);
const requests: InternalEventPayload[] = [];
const reply = (event: InternalEvent, payload: InternalEventPayload) =>
instance.postMessage({ ...event, id: `reply-${event.id}`, replyId: event.id, payload });
const toast = new Promise<string>((resolve, reject) => {
channel.listen((event) => {
const { payload } = event;
if (payload.type === "filter_text_request") {
// Match the JSON transport used by the real plugin host.
requests.push(JSON.parse(JSON.stringify(payload)) as InternalEventPayload);
reply(
event,
payload.language === "jsonpath"
? { type: "filter_text_response", content: "[\n 1\n]" }
: { type: "filter_text_response", content: "", error: "Invalid filter: bad" },
);
} else if (payload.type === "show_toast_request") {
resolve(payload.message);
} else if (payload.type === "error_response") {
reject(new Error(payload.error));
}
});
});
try {
instance.postMessage({
id: "boot",
replyId: null,
pluginRefId: "test",
pluginName: "test",
context,
payload: { type: "boot_request", ...bootRequest },
});
expect(JSON.parse(await toast)).toEqual([
{ content: "[\n 1\n]" },
{ content: "", error: "Invalid filter: bad" },
]);
expect(requests).toEqual([
{ type: "filter_text_request", language: "jsonpath", text: '{"a":1}', query: "$.a" },
{ type: "filter_text_request", language: "xpath", text: "<a/>", query: "//b[" },
]);
} finally {
await instance.terminate();
await rm(dir, { recursive: true, force: true });
}
});
@@ -107,6 +107,29 @@ describe("segmentsToJsonPath", () => {
});
describe("jsonPathToSegments", () => {
// The filter side of these keys is tested in crates/yaak-filter.
test.each([
"identifier",
"a.b",
"space key",
"",
'quote"key',
"back\\slash",
"combined\"\\]'key",
"*",
"$",
"$foo",
"?(true)",
"line\nbreak\t\u0000",
"emoji 🤔",
"__proto__",
])("round-trips the caret path to the literal key %j", (key) => {
const doc = JSON.stringify({ [key]: "needle-value", decoy: "wrong-value" }, null, 2);
const segments = jsonPathSegmentsAt(stateFor(doc), doc.indexOf('"needle-value"') + 4);
expect(segments).toEqual([{ kind: "key", key }]);
expect(jsonPathToSegments(segmentsToJsonPath(segments ?? []))).toEqual(segments);
});
test("round-trips paths built by segmentsToJsonPath", () => {
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
const segments = jsonPathToSegments(path);
-59
View File
@@ -1,59 +0,0 @@
# JSONPath
A filter plugin that enables [JSONPath](https://en.wikipedia.org/wiki/JSONPath)
extraction and filtering for JSON responses, making it easy to extract specific values
from complex JSON structures.
![Screenshot of JSONPath filtering](screenshot.png)
## Overview
This plugin provides JSONPath filtering for responses in Yaak. JSONPath is a query
language for JSON, similar to XPath for XML, that provides the ability to extract data
from JSON documents using a simple, expressive syntax. This is useful for working with
complex API responses where you need to only view a small subset of response data.
## How JSONPath Works
JSONPath uses a dot-notation syntax to navigate JSON structures:
- `$` - Root element
- `.` - Child element
- `..` - Recursive descent
- `*` - Wildcard
- `[]` - Array index or filter
## JSONPath Syntax Examples
### Basic Navigation
```
$.store.book[0].title # First book title
$.store.book[*].author # All book authors
$.store.book[-1] # Last book
$.store.book[0,1] # First two books
$.store.book[0:2] # First two books (slice)
```
### Filtering
```
$.store.book[?(@.price < 10)] # Books under $10
$.store.book[?(@.author == 'Tolkien')] # Books by Tolkien
$.store.book[?(@.category == 'fiction')] # Fiction books
```
### Recursive Search
```
$..author # All authors anywhere in the document
$..book[2] # Third book anywhere
$..price # All prices in the document
```
## Usage
1. Make an API request that returns JSON data
2. Below the response body, click the filter icon
3. Enter a JSONPath expression
4. View the extracted data in the results panel
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@yaak/filter-jsonpath",
"displayName": "JSONPath Filter",
"version": "0.1.0",
"private": true,
"description": "Filter JSON response data using JSONPath expressions",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/filter-jsonpath"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"@yaakapp-internal/lib": "^1.0.0",
"jsonpath-plus": "^11.0.0"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

-39
View File
@@ -1,39 +0,0 @@
import type { PluginDefinition } from "@yaakapp/api";
import { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
import { JSONPath } from "jsonpath-plus";
export const plugin: PluginDefinition = {
filter: {
name: "JSONPath",
description: "Filter JSONPath",
onFilter(_ctx, args) {
const parsed = JSON.parse(args.payload);
try {
// jsonpath-plus doesn't decode JSON-quoted keys reliably and can treat
// their contents as operators. Only complete literal paths take this
// route; all other expressions retain the library's existing behavior.
const segments = args.filter.includes('["') ? jsonPathToSegments(args.filter) : null;
let filtered: unknown;
if (segments != null) {
let value: unknown = parsed;
for (const segment of segments) {
const key = segment.kind === "key" ? segment.key : segment.index;
if (value == null || !Object.prototype.hasOwnProperty.call(value, key)) {
return { content: "[]" };
}
value = (value as Record<string, unknown>)[key];
}
filtered = [value];
} else {
filtered = JSONPath({ path: args.filter, json: parsed });
}
return { content: JSON.stringify(filtered, null, 2) };
} catch (err) {
return {
content: "",
error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
},
};
@@ -1,130 +0,0 @@
import { forceParsing } from "@codemirror/language";
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { JSONPath } from "jsonpath-plus";
import { describe, expect, test } from "vite-plus/test";
import {
jsonPathSegmentsAt,
jsonPathToSegments,
segmentsToJsonPath,
} from "../../../packages/ui/src/components/editor/json/jsonPath";
import { plugin } from "../src";
async function filter(json: unknown, path: string) {
const result = await plugin.filter!.onFilter({} as never, {
payload: JSON.stringify(json),
filter: path,
mimeType: "application/json",
});
expect(result.error).toBeUndefined();
return JSON.parse(result.content);
}
/** Exercise the caret path and expression the breadcrumb button actually sends. */
function breadcrumb(json: unknown) {
const doc = JSON.stringify(json, null, 2);
const state = EditorState.create({ doc, extensions: [jsonc()] });
forceParsing({ state } as never, doc.length, 5000);
const segments = jsonPathSegmentsAt(state, doc.indexOf('"needle-value"') + 4);
expect(segments).not.toBeNull();
const path = segmentsToJsonPath(segments ?? []);
expect(jsonPathToSegments(path)).toEqual(segments);
return path;
}
describe("breadcrumb paths through the real filter plugin", () => {
test.each([
"identifier",
"a.b",
"space key",
"",
'quote"key',
"single'key",
"back\\slash",
"bracket]key",
"combined\"\\]'key",
"*",
"..",
"$",
"^",
"~",
"a,b",
"0:2",
"?(true)",
"(@.length-1)",
"@number()",
"`key",
"x)]y",
"x)']y",
"semi;colon",
"percent%@%key",
"line\nbreak\t\u0000",
"emoji 🤔",
"lone surrogate \ud800",
"__proto__",
"constructor",
])("selects the literal key %j, not an operator or expression", async (key) => {
const json = Object.fromEntries([
[key, "needle-value"],
["decoy", "wrong-value"],
]);
expect(await filter(json, breadcrumb(json))).toEqual(["needle-value"]);
});
test("nested unusual keys and arrays round-trip and filter at every crumb", async () => {
const inner = Object.fromEntries([["inner'\\]", { "": "needle-value" }]]);
const json = { outer: Object.fromEntries([['quote"key', ["decoy", inner]]]) };
const path = breadcrumb(json);
const segments = jsonPathToSegments(path)!;
expect(await filter(json, path)).toEqual(["needle-value"]);
expect(await filter(json, segmentsToJsonPath(segments, 4))).toEqual([{ "": "needle-value" }]);
expect(await filter(json, segmentsToJsonPath(segments, 3))).toEqual([inner]);
expect(await filter(json, segmentsToJsonPath(segments, 2))).toEqual([["decoy", inner]]);
expect(await filter(json, segmentsToJsonPath(segments, 1))).toEqual([json.outer]);
expect(await filter(json, segmentsToJsonPath(segments, 0))).toEqual([json]);
});
test("root arrays and quoted numeric object keys remain distinct", async () => {
const json = ["decoy", { "01": { "1": "needle-value" } }];
expect(await filter(json, breadcrumb(json))).toEqual(["needle-value"]);
expect(await filter(json, '$[1]["01"]["missing"]')).toEqual([]);
});
test.each([null, false, 0, "", [1, 2], { value: 3 }])(
"retains the matched value %j",
async (value) => {
expect(await filter({ 'quote"key': value }, '$["quote\\\"key"]')).toEqual([value]);
},
);
test("only follows own properties, while allowing actual prototype-named JSON keys", async () => {
const json = JSON.parse('{"a.b":{"__proto__":{"constructor":"own-value"}}}');
expect(await filter(json, '$["a.b"].__proto__.constructor')).toEqual(["own-value"]);
expect(await filter(json, '$["a.b"].constructor')).toEqual([]);
expect(await filter(json, '$["a.b"].__proto__.toString')).toEqual([]);
expect(await filter(json, '$["a.b"].__proto__.constructor.__proto__')).toEqual([]);
});
});
describe("existing JSONPath expressions", () => {
const json = { items: [{ id: 1 }, { id: 2 }, { id: 3 }], "space key": 4 };
test.each([
["$", [json]],
["$.items[1].id", [2]],
['$["space key"]', [4]],
["$['space key']", [4]],
["$.items[*].id", [1, 2, 3]],
["$..id", [1, 2, 3]],
["$.items[0:2].id", [1, 2]],
["$.items[0,2].id", [1, 3]],
["$.items[?(@.id > 1)].id", [2, 3]],
['$.items[?(@["id"] > 1)].id', [2, 3]],
["$.items[(@.length-1)].id", [3]],
['$["items"][*].id', [1, 2, 3]],
['$["items"].$', [json.items]],
["$.missing", []],
])("preserves %s", async (path, expected) => {
expect(await filter(json, path as string)).toEqual(expected);
expect(await filter(json, path as string)).toEqual(JSONPath({ path: path as string, json }));
});
});
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "@yaak/filter-xpath",
"displayName": "XPath Filter",
"version": "0.1.0",
"private": true,
"description": "Filter response XML data using XPath expressions",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}
-28
View File
@@ -1,28 +0,0 @@
/* oxlint-disable no-base-to-string */
import { DOMParser } from "@xmldom/xmldom";
import type { PluginDefinition } from "@yaakapp/api";
import xpath from "xpath";
export const plugin: PluginDefinition = {
filter: {
name: "XPath",
description: "Filter XPath",
onFilter(_ctx, args) {
// oxlint-disable-next-line no-explicit-any
const doc: any = new DOMParser().parseFromString(args.payload, "text/xml");
try {
const result = xpath.select(args.filter, doc, false);
if (Array.isArray(result)) {
return { content: result.map((r) => String(r)).join("\n") };
}
// Not sure what cases this happens in (?)
return { content: String(result) };
} catch (err) {
return {
content: "",
error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
},
};
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}