mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Move response filters into Rust (#755)
This commit is contained in:
1 parent
19908b1902
commit
9b3715024d
42 files changed
+15538
-399
No files matched your search
Generated
+46
@@ -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"
|
||||
|
||||
@@ -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: "#));
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("ED, &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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'&' => out.push_str("&"),
|
||||
'"' if attribute => out.push_str("""),
|
||||
'\t' if attribute => out.push_str("	"),
|
||||
'\n' if attribute => out.push_str(" "),
|
||||
'\r' if attribute => out.push_str(" "),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+8710
File diff suppressed because it is too large.
Load diff
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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 & 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"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 & C</item>"
|
||||
);
|
||||
assert_eq!(content(ITEMS, "//item/text()"), "A\nB & 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"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"2"><!--note--><b><raw></b></a>"#
|
||||
);
|
||||
assert_eq!(content(MIXED, "//b/text()"), "<raw>");
|
||||
assert_eq!(content("<a>x<![CDATA[<y>]]>&z</a>", "/a/text()"), "x<y>&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
@@ -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;
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
-29
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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 |
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
Reference in new issue
Block a user