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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "peresil"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f658886ed52e196e850cfbbfddab9eaa7f6d90dd0929e264c31e5cec07e09e57"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest"
|
name = "pest"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -8373,6 +8379,27 @@ dependencies = [
|
|||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -9730,6 +9757,12 @@ dependencies = [
|
|||||||
"thiserror 2.0.21",
|
"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]]
|
[[package]]
|
||||||
name = "typedmap"
|
name = "typedmap"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -11173,6 +11206,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"yaak-core",
|
"yaak-core",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
|
"yaak-filter",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
"yaak-plugins",
|
"yaak-plugins",
|
||||||
@@ -11240,6 +11274,7 @@ dependencies = [
|
|||||||
"yaak-common",
|
"yaak-common",
|
||||||
"yaak-core",
|
"yaak-core",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
|
"yaak-filter",
|
||||||
"yaak-fonts",
|
"yaak-fonts",
|
||||||
"yaak-git",
|
"yaak-git",
|
||||||
"yaak-grpc",
|
"yaak-grpc",
|
||||||
@@ -11390,6 +11425,17 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yaak-filter"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"fancy-regex",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"serde_json",
|
||||||
|
"sxd-document",
|
||||||
|
"sxd-xpath",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yaak-fonts"
|
name = "yaak-fonts"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ members = [
|
|||||||
"crates/yaak-core",
|
"crates/yaak-core",
|
||||||
"crates/yaak-common",
|
"crates/yaak-common",
|
||||||
"crates/yaak-crypto",
|
"crates/yaak-crypto",
|
||||||
|
"crates/yaak-filter",
|
||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-grpc",
|
"crates/yaak-grpc",
|
||||||
"crates/yaak-http",
|
"crates/yaak-http",
|
||||||
@@ -86,6 +87,7 @@ yaak = { path = "crates/yaak" }
|
|||||||
yaak-commands = { path = "crates/yaak-commands" }
|
yaak-commands = { path = "crates/yaak-commands" }
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
yaak-common = { path = "crates/yaak-common" }
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||||
|
yaak-filter = { path = "crates/yaak-filter" }
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
yaak-git = { path = "crates/yaak-git" }
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
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 = { workspace = true }
|
||||||
yaak-commands = { workspace = true }
|
yaak-commands = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
|
yaak-filter = { workspace = true }
|
||||||
yaak-fonts = { workspace = true }
|
yaak-fonts = { workspace = true }
|
||||||
yaak-git = { workspace = true }
|
yaak-git = { workspace = true }
|
||||||
yaak-grpc = { 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()))?;
|
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
||||||
|
|
||||||
match filter {
|
match filter {
|
||||||
Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
|
Some(filter) if !filter.is_empty() => {
|
||||||
.await?
|
let language = yaak_filter::Language::for_content_type(content_type);
|
||||||
.filter_data(&window.plugin_context(), filter, &body, content_type)
|
let result = yaak_filter::filter(language, &body, filter);
|
||||||
.await?),
|
Ok(FilterResponse { content: result.content, error: result.error })
|
||||||
|
}
|
||||||
_ => Ok(FilterResponse { content: body, error: None }),
|
_ => 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>, };
|
extensions: Array<string>, };
|
||||||
|
|
||||||
|
export type FilterLanguage = "jsonpath" | "xpath";
|
||||||
|
|
||||||
export type FilterRequest = { content: string, filter: string, };
|
export type FilterRequest = { content: string, filter: string, };
|
||||||
|
|
||||||
export type FilterResponse = { content: string, error?: 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 FindHttpResponsesRequest = { requestId: string, limit?: number, };
|
||||||
|
|
||||||
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
|
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 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;
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ pub enum InternalEventPayload {
|
|||||||
|
|
||||||
FilterRequest(FilterRequest),
|
FilterRequest(FilterRequest),
|
||||||
FilterResponse(FilterResponse),
|
FilterResponse(FilterResponse),
|
||||||
|
FilterTextRequest(FilterTextRequest),
|
||||||
|
FilterTextResponse(FilterResponse),
|
||||||
|
|
||||||
ExportHttpRequestRequest(ExportHttpRequestRequest),
|
ExportHttpRequestRequest(ExportHttpRequestRequest),
|
||||||
ExportHttpRequestResponse(ExportHttpRequestResponse),
|
ExportHttpRequestResponse(ExportHttpRequestResponse),
|
||||||
@@ -286,9 +288,28 @@ pub struct FilterRequest {
|
|||||||
pub struct FilterResponse {
|
pub struct FilterResponse {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub error: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_events.ts")]
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ use crate::events::{
|
|||||||
CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest,
|
CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest,
|
||||||
CallTemplateFunctionArgs, CallTemplateFunctionRequest, CallTemplateFunctionResponse,
|
CallTemplateFunctionArgs, CallTemplateFunctionRequest, CallTemplateFunctionResponse,
|
||||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, EmptyPayload, ErrorResponse,
|
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, EmptyPayload, ErrorResponse,
|
||||||
FilterRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest,
|
||||||
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,
|
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
GetHttpRequestActionsResponse, GetTemplateFunctionConfigRequest,
|
||||||
GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse,
|
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesRequest,
|
||||||
GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse,
|
GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
|
||||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportRequest, ImportResponse,
|
ImportRequest, ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive,
|
||||||
InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose,
|
PluginContext, RenderPurpose, ShowToastRequest,
|
||||||
ShowToastRequest,
|
|
||||||
};
|
};
|
||||||
use crate::native_template_functions::{template_function_keyring, template_function_secure};
|
use crate::native_template_functions::{template_function_keyring, template_function_secure};
|
||||||
use crate::nodejs::start_nodejs_plugin_runtime;
|
use crate::nodejs::start_nodejs_plugin_runtime;
|
||||||
@@ -1139,46 +1138,6 @@ impl PluginManager {
|
|||||||
Some(resp) => Ok(resp),
|
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 {
|
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`.
|
/// 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
|
/// `exporter-curl` was renamed to `action-copy-curl` in 19ffcd18, which is why affected
|
||||||
/// installs show "Copy as cURL" twice.
|
/// installs show "Copy as cURL" twice. `filter-jsonpath` and `filter-xpath` moved into
|
||||||
const REMOVED_BUNDLED_PLUGIN_DIRS: &[&str] = &["exporter-curl"];
|
/// 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.
|
/// Whether a plugin directory path is one of the known-removed bundled plugins.
|
||||||
fn is_removed_bundled_plugin_dir(dir: &str) -> bool {
|
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"
|
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/exporter-curl/"));
|
||||||
|
assert!(is_removed_bundled_plugin_dir("vendored/plugins/filter-jsonpath"));
|
||||||
|
assert!(is_removed_bundled_plugin_dir("vendored/plugins/filter-xpath"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ tokio = { workspace = true, features = ["sync", "rt"] }
|
|||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-core = { workspace = true }
|
yaak-core = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
|
yaak-filter = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-plugins = { workspace = true }
|
yaak-plugins = { workspace = true }
|
||||||
yaak-templates = { workspace = true }
|
yaak-templates = { workspace = true }
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ use yaak_models::query_manager::QueryManager;
|
|||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
||||||
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FilterLanguage, FilterResponse,
|
||||||
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
FilterTextRequest, FindHttpResponsesRequest, FindHttpResponsesResponse, GetCookieValueRequest,
|
||||||
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
|
GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest,
|
||||||
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
|
GetHttpResponseBodyInfoResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
|
||||||
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
|
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
|
||||||
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
|
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
|
||||||
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
|
PromptFormRequest, PromptTextRequest, ReadHttpResponseBodyChunkRequest,
|
||||||
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
|
ReadHttpResponseBodyChunkResponse, ReloadResponse, RenderGrpcRequestRequest,
|
||||||
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
|
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
|
||||||
UpsertModelResponse, WindowInfoRequest,
|
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SharedPluginEventContext<'a> {
|
pub struct SharedPluginEventContext<'a> {
|
||||||
@@ -50,6 +50,7 @@ pub enum SharedRequest<'a> {
|
|||||||
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
|
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
|
||||||
UpsertModel(&'a UpsertModelRequest),
|
UpsertModel(&'a UpsertModelRequest),
|
||||||
DeleteModel(&'a DeleteModelRequest),
|
DeleteModel(&'a DeleteModelRequest),
|
||||||
|
FilterText(&'a FilterTextRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -156,6 +157,9 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
|||||||
InternalEventPayload::DeleteModelRequest(req) => {
|
InternalEventPayload::DeleteModelRequest(req) => {
|
||||||
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
|
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
|
||||||
}
|
}
|
||||||
|
InternalEventPayload::FilterTextRequest(req) => {
|
||||||
|
GroupedPluginRequest::Shared(SharedRequest::FilterText(req))
|
||||||
|
}
|
||||||
InternalEventPayload::RenderGrpcRequestRequest(req) => {
|
InternalEventPayload::RenderGrpcRequestRequest(req) => {
|
||||||
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
|
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
|
||||||
}
|
}
|
||||||
@@ -485,6 +489,17 @@ fn build_shared_reply(
|
|||||||
|
|
||||||
InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model })
|
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
|
/// A store that answers from memory, standing in for whatever holds the
|
||||||
/// bytes — the point being that the dispatch below never learns which.
|
/// bytes — the point being that the dispatch below never learns which.
|
||||||
struct FakeBodyStore {
|
struct FakeBodyStore {
|
||||||
|
|||||||
Generated
-29
@@ -31,8 +31,6 @@
|
|||||||
"plugins/auth-ntlm",
|
"plugins/auth-ntlm",
|
||||||
"plugins/auth-oauth2",
|
"plugins/auth-oauth2",
|
||||||
"plugins/auth-oauth1",
|
"plugins/auth-oauth1",
|
||||||
"plugins/filter-jsonpath",
|
|
||||||
"plugins/filter-xpath",
|
|
||||||
"plugins/importer-bruno",
|
"plugins/importer-bruno",
|
||||||
"plugins/importer-curl",
|
"plugins/importer-curl",
|
||||||
"plugins/importer-insomnia",
|
"plugins/importer-insomnia",
|
||||||
@@ -5183,14 +5181,6 @@
|
|||||||
"resolved": "plugins-external/faker",
|
"resolved": "plugins-external/faker",
|
||||||
"link": true
|
"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": {
|
"node_modules/@yaak/httpsnippet": {
|
||||||
"resolved": "plugins-external/httpsnippet",
|
"resolved": "plugins-external/httpsnippet",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -15188,25 +15178,6 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.7"
|
"@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": {
|
"plugins/importer-bruno": {
|
||||||
"name": "@yaak/importer-bruno",
|
"name": "@yaak/importer-bruno",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -30,8 +30,6 @@
|
|||||||
"plugins/auth-ntlm",
|
"plugins/auth-ntlm",
|
||||||
"plugins/auth-oauth2",
|
"plugins/auth-oauth2",
|
||||||
"plugins/auth-oauth1",
|
"plugins/auth-oauth1",
|
||||||
"plugins/filter-jsonpath",
|
|
||||||
"plugins/filter-xpath",
|
|
||||||
"plugins/importer-bruno",
|
"plugins/importer-bruno",
|
||||||
"plugins/importer-curl",
|
"plugins/importer-curl",
|
||||||
"plugins/importer-insomnia",
|
"plugins/importer-insomnia",
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
|||||||
if (str(payload, "filter") != null) {
|
if (str(payload, "filter") != null) {
|
||||||
return {
|
return {
|
||||||
content: "",
|
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>, };
|
extensions: Array<string>, };
|
||||||
|
|
||||||
|
export type FilterLanguage = "jsonpath" | "xpath";
|
||||||
|
|
||||||
export type FilterRequest = { content: string, filter: string, };
|
export type FilterRequest = { content: string, filter: string, };
|
||||||
|
|
||||||
export type FilterResponse = { content: string, error?: 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 FindHttpResponsesRequest = { requestId: string, limit?: number, };
|
||||||
|
|
||||||
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
|
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 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;
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
FilterResponse,
|
||||||
FindHttpResponsesRequest,
|
FindHttpResponsesRequest,
|
||||||
FindHttpResponsesResponse,
|
FindHttpResponsesResponse,
|
||||||
FormInput,
|
FormInput,
|
||||||
@@ -226,6 +227,16 @@ export interface Context {
|
|||||||
templates: {
|
templates: {
|
||||||
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
|
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: {
|
plugin: {
|
||||||
reload(): void;
|
reload(): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
BootRequest,
|
BootRequest,
|
||||||
DeleteKeyValueResponse,
|
DeleteKeyValueResponse,
|
||||||
DeleteModelResponse,
|
DeleteModelResponse,
|
||||||
|
FilterLanguage,
|
||||||
|
FilterResponse,
|
||||||
FindHttpResponsesResponse,
|
FindHttpResponsesResponse,
|
||||||
Folder,
|
Folder,
|
||||||
FormInput,
|
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 () => {
|
const _windowInfo = async () => {
|
||||||
if (context.label == null) {
|
if (context.label == null) {
|
||||||
throw new Error("Can't get window context without an active window");
|
throw new Error("Can't get window context without an active window");
|
||||||
@@ -1003,6 +1013,10 @@ export class PluginInstance {
|
|||||||
return result.data as any;
|
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: {
|
store: {
|
||||||
get: async <T>(key: string) => {
|
get: async <T>(key: string) => {
|
||||||
const payload = { type: "get_key_value_request", key } as const;
|
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", () => {
|
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", () => {
|
test("round-trips paths built by segmentsToJsonPath", () => {
|
||||||
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
|
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
|
||||||
const segments = jsonPathToSegments(path);
|
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