diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a6246..943905a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to eww will be listed here, starting at changes since versio ## [Unreleased] +### Features +- Add support for safe access (`?.`) in simplexpr (By: oldwomanjosiah) + ## [0.4.0] (04.09.2022) ### BREAKING CHANGES diff --git a/crates/simplexpr/src/ast.rs b/crates/simplexpr/src/ast.rs index 4edf00b..aa961d1 100644 --- a/crates/simplexpr/src/ast.rs +++ b/crates/simplexpr/src/ast.rs @@ -33,6 +33,13 @@ pub enum UnaryOp { Negative, } +/// Differenciates between regular field access (`foo.bar`) and null-safe field access (`foo?.bar`) +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AccessType { + Normal, + Safe, +} + #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SimplExpr { Literal(DynVal), @@ -43,7 +50,7 @@ pub enum SimplExpr { BinOp(Span, Box, BinOp, Box), UnaryOp(Span, UnaryOp, Box), IfElse(Span, Box, Box, Box), - JsonAccess(Span, Box, Box), + JsonAccess(Span, AccessType, Box, Box), FunctionCall(Span, String, Vec), } @@ -65,7 +72,8 @@ impl std::fmt::Display for SimplExpr { SimplExpr::BinOp(_, l, op, r) => write!(f, "({} {} {})", l, op, r), SimplExpr::UnaryOp(_, op, x) => write!(f, "{}{}", op, x), SimplExpr::IfElse(_, a, b, c) => write!(f, "({} ? {} : {})", a, b, c), - SimplExpr::JsonAccess(_, value, index) => write!(f, "{}[{}]", value, index), + SimplExpr::JsonAccess(_, AccessType::Normal, value, index) => write!(f, "{}[{}]", value, index), + SimplExpr::JsonAccess(_, AccessType::Safe, value, index) => write!(f, "{}?.[{}]", value, index), SimplExpr::FunctionCall(_, function_name, args) => { write!(f, "{}({})", function_name, args.iter().join(", ")) } @@ -101,7 +109,7 @@ impl SimplExpr { Literal(_) => false, Concat(_, x) | FunctionCall(_, _, x) | JsonArray(_, x) => x.iter().any(|x| x.references_var(var)), JsonObject(_, x) => x.iter().any(|(k, v)| k.references_var(var) || v.references_var(var)), - JsonAccess(_, a, b) | BinOp(_, a, _, b) => a.references_var(var) || b.references_var(var), + JsonAccess(_, _, a, b) | BinOp(_, a, _, b) => a.references_var(var) || b.references_var(var), UnaryOp(_, _, x) => x.references_var(var), IfElse(_, a, b, c) => a.references_var(var) || b.references_var(var) || c.references_var(var), VarRef(_, x) => x == var, @@ -113,7 +121,7 @@ impl SimplExpr { match self { VarRef(_, x) => dest.push(x.clone()), UnaryOp(_, _, x) => x.as_ref().collect_var_refs_into(dest), - BinOp(_, a, _, b) | JsonAccess(_, a, b) => { + BinOp(_, a, _, b) | JsonAccess(_, _, a, b) => { a.as_ref().collect_var_refs_into(dest); b.as_ref().collect_var_refs_into(dest); } diff --git a/crates/simplexpr/src/eval.rs b/crates/simplexpr/src/eval.rs index 21c950b..b31db4a 100644 --- a/crates/simplexpr/src/eval.rs +++ b/crates/simplexpr/src/eval.rs @@ -1,7 +1,7 @@ use itertools::Itertools; use crate::{ - ast::{BinOp, SimplExpr, UnaryOp}, + ast::{AccessType, BinOp, SimplExpr, UnaryOp}, dynval::{ConversionError, DynVal}, }; use eww_shared_util::{Span, Spanned, VarName}; @@ -75,7 +75,9 @@ impl SimplExpr { IfElse(span, box a, box b, box c) => { IfElse(span, box a.try_map_var_refs(f)?, box b.try_map_var_refs(f)?, box c.try_map_var_refs(f)?) } - JsonAccess(span, box a, box b) => JsonAccess(span, box a.try_map_var_refs(f)?, box b.try_map_var_refs(f)?), + JsonAccess(span, safe, box a, box b) => { + JsonAccess(span, safe, box a.try_map_var_refs(f)?, box b.try_map_var_refs(f)?) + } FunctionCall(span, name, args) => { FunctionCall(span, name, args.into_iter().map(|x| x.try_map_var_refs(f)).collect::>()?) } @@ -124,7 +126,7 @@ impl SimplExpr { Literal(..) => Vec::new(), VarRef(span, name) => vec![(*span, name)], Concat(_, elems) => elems.iter().flat_map(|x| x.var_refs_with_span().into_iter()).collect(), - BinOp(_, box a, _, box b) | JsonAccess(_, box a, box b) => { + BinOp(_, box a, _, box b) | JsonAccess(_, _, box a, box b) => { let mut refs = a.var_refs_with_span(); refs.extend(b.var_refs_with_span().iter()); refs @@ -195,8 +197,14 @@ impl SimplExpr { BinOp::LT => DynVal::from(a.as_f64()? < b.as_f64()?), BinOp::GE => DynVal::from(a.as_f64()? >= b.as_f64()?), BinOp::LE => DynVal::from(a.as_f64()? <= b.as_f64()?), - #[allow(clippy::useless_conversion)] - BinOp::Elvis => DynVal::from(if a.0.is_empty() { b } else { a }), + BinOp::Elvis => { + let is_null = matches!(serde_json::from_str(&a.0), Ok(serde_json::Value::Null)); + if a.0.is_empty() || is_null { + b + } else { + a + } + } BinOp::RegexMatch => { let regex = regex::Regex::new(&b.as_string()?)?; DynVal::from(regex.is_match(&a.as_string()?)) @@ -218,9 +226,12 @@ impl SimplExpr { no.eval(values) } } - SimplExpr::JsonAccess(span, val, index) => { + SimplExpr::JsonAccess(span, safe, val, index) => { let val = val.eval(values)?; let index = index.eval(values)?; + + let is_safe = *safe == AccessType::Safe; + match val.as_json_value()? { serde_json::Value::Array(val) => { let index = index.as_i32()?; @@ -234,6 +245,10 @@ impl SimplExpr { .unwrap_or(&serde_json::Value::Null); Ok(DynVal::from(indexed_value).at(*span)) } + serde_json::Value::String(val) if val.is_empty() && is_safe => { + Ok(DynVal::from(&serde_json::Value::Null).at(*span)) + } + serde_json::Value::Null if is_safe => Ok(DynVal::from(&serde_json::Value::Null).at(*span)), _ => Err(EvalError::CannotIndex(format!("{}", val)).at(*span)), } } @@ -330,3 +345,55 @@ fn call_expr_function(name: &str, args: Vec) -> Result Err(EvalError::UnknownFunction(name.to_string())), } } + +#[cfg(test)] +mod tests { + use crate::dynval::DynVal; + + macro_rules! evals_as { + ($name:ident($simplexpr:expr) => $expected:expr $(,)?) => { + #[test] + fn $name() { + let expected: Result<$crate::dynval::DynVal, $crate::eval::EvalError> = $expected; + + let parsed = match $crate::parser::parse_string(0, 0, $simplexpr.into()) { + Ok(it) => it, + Err(e) => { + panic!("Could not parse input as SimpleExpr\nInput: {}\nReason: {}", stringify!($simplexpr), e); + } + }; + + eprintln!("Parsed as {parsed:#?}"); + + let output = parsed.eval_no_vars(); + + match expected { + Ok(expected) => { + let actual = output.expect("Output was not Ok(_)"); + + assert_eq!(expected, actual); + } + Err(expected) => { + let actual = output.expect_err("Output was not Err(_)").to_string(); + let expected = expected.to_string(); + + assert_eq!(expected, actual); + } + } + } + }; + + ($name:ident($simplexpr:expr) => $expected:expr, $($tt:tt)+) => { + evals_as!($name($simplexpr) => $expected); + evals_as!($($tt)*); + } + } + + evals_as! { + string_to_string(r#""Hello""#) => Ok(DynVal::from("Hello".to_string())), + safe_access_to_existing(r#"{ "a": { "b": 2 } }.a?.b"#) => Ok(DynVal::from(2)), + safe_access_to_missing(r#"{ "a": { "b": 2 } }.b?.b"#) => Ok(DynVal::from(&serde_json::Value::Null)), + normal_access_to_existing(r#"{ "a": { "b": 2 } }.a.b"#) => Ok(DynVal::from(2)), + normal_access_to_missing(r#"{ "a": { "b": 2 } }.b.b"#) => Err(super::EvalError::CannotIndex("null".to_string())), + } +} diff --git a/crates/simplexpr/src/parser/lexer.rs b/crates/simplexpr/src/parser/lexer.rs index 4ced45c..34b0ed6 100644 --- a/crates/simplexpr/src/parser/lexer.rs +++ b/crates/simplexpr/src/parser/lexer.rs @@ -28,6 +28,7 @@ pub enum Token { GT, LT, Elvis, + SafeAccess, RegexMatch, Not, @@ -88,6 +89,7 @@ regex_rules! { r">" => |_| Token::GT, r"<" => |_| Token::LT, r"\?:" => |_| Token::Elvis, + r"\?\." => |_| Token::SafeAccess, r"=~" => |_| Token::RegexMatch, r"!" => |_| Token::Not, @@ -318,5 +320,6 @@ mod test { "${ {"hi": "ho"}.hi }".hi "#), empty_interpolation => v!(r#""${}""#), + safe_interpolation => v!(r#""${ { "key": "value" }.key1?.key2 ?: "Recovery" }""#), } } diff --git a/crates/simplexpr/src/parser/mod.rs b/crates/simplexpr/src/parser/mod.rs index a9bfdf7..5792288 100644 --- a/crates/simplexpr/src/parser/mod.rs +++ b/crates/simplexpr/src/parser/mod.rs @@ -41,6 +41,7 @@ mod tests { "foo.bar[2 + 2] * asdf[foo.bar]", r#"[1, 2, 3 + 4, "bla", [blub, blo]]"#, r#"{ "key": "value", 5: 1+2, true: false }"#, + r#"{ "key": "value" }?.key?.does_not_exist"#, ); } } diff --git a/crates/simplexpr/src/parser/snapshots/simplexpr__parser__lexer__test__safe_interpolation.snap b/crates/simplexpr/src/parser/snapshots/simplexpr__parser__lexer__test__safe_interpolation.snap new file mode 100644 index 0000000..88aa682 --- /dev/null +++ b/crates/simplexpr/src/parser/snapshots/simplexpr__parser__lexer__test__safe_interpolation.snap @@ -0,0 +1,5 @@ +--- +source: crates/simplexpr/src/parser/lexer.rs +expression: "v!(r#\"\"${ { \"key\": \"value\" }.key1?.key2 ?: \"Recovery\" }\"\"#)" +--- +(0, StringLit([(0, Literal(""), 3), (3, Interp([(4, LCurl, 5), (6, StringLit([(6, Literal("key"), 11)]), 11), (11, Colon, 12), (13, StringLit([(13, Literal("value"), 20)]), 20), (21, RCurl, 22), (22, Dot, 23), (23, Ident("key1"), 27), (27, SafeAccess, 29), (29, Ident("key2"), 33), (34, Elvis, 36), (37, StringLit([(37, Literal("Recovery"), 47)]), 47)]), 48), (48, Literal(""), 50)]), 50) diff --git a/crates/simplexpr/src/parser/snapshots/simplexpr__parser__tests__test-16.snap b/crates/simplexpr/src/parser/snapshots/simplexpr__parser__tests__test-16.snap new file mode 100644 index 0000000..0c57acc --- /dev/null +++ b/crates/simplexpr/src/parser/snapshots/simplexpr__parser__tests__test-16.snap @@ -0,0 +1,7 @@ +--- +source: crates/simplexpr/src/parser/mod.rs +expression: "p.parse(0, Lexer::new(0, 0, r#\"{ \"key\": \"value\" }?.key?.does_not_exist\"#))" +--- +Ok( + {"key": "value"}?.["key"]?.["does_not_exist"], +) diff --git a/crates/simplexpr/src/simplexpr_parser.lalrpop b/crates/simplexpr/src/simplexpr_parser.lalrpop index c63a644..c215ee4 100644 --- a/crates/simplexpr/src/simplexpr_parser.lalrpop +++ b/crates/simplexpr/src/simplexpr_parser.lalrpop @@ -1,4 +1,4 @@ -use crate::ast::{SimplExpr::{self, *}, BinOp::*, UnaryOp::*}; +use crate::ast::{SimplExpr::{self, *}, BinOp::*, UnaryOp::*, AccessType}; use eww_shared_util::{Span, VarName}; use crate::parser::lexer::{Token, LexicalError, StrLitSegment, Sp}; use crate::parser::lalrpop_helpers::*; @@ -25,6 +25,7 @@ extern { ">" => Token::GT, "<" => Token::LT, "?:" => Token::Elvis, + "?." => Token::SafeAccess, "=~" => Token::RegexMatch, "!" => Token::Not, @@ -75,10 +76,16 @@ pub Expr: SimplExpr = { #[precedence(level="1")] #[assoc(side="right")] "(" > ")" => FunctionCall(Span(l, r, fid), ident, args), - "[" "]" => JsonAccess(Span(l, r, fid), b(value), b(index)), + "[" "]" => { + JsonAccess(Span(l, r, fid), AccessType::Normal, b(value), b(index)) + }, "." => { - JsonAccess(Span(l, r, fid), b(value), b(Literal(index.into()))) + JsonAccess(Span(l, r, fid), AccessType::Normal, b(value), b(Literal(index.into()))) + }, + + "?." => { + JsonAccess(Span(l, r, fid), AccessType::Safe, b(value), b(Literal(index.into()))) }, #[precedence(level="2")] #[assoc(side="right")] diff --git a/docs/src/expression_language.md b/docs/src/expression_language.md index 848d445..e00da1c 100644 --- a/docs/src/expression_language.md +++ b/docs/src/expression_language.md @@ -24,7 +24,14 @@ Supported currently are the following features: - comparisons (`==`, `!=`, `>`, `<`, `<=`, `>=`) - boolean operations (`||`, `&&`, `!`) - elvis operator (`?:`) - - if the left side is `""`, then returns the right side, otherwise evaluates to the left side. + - if the left side is `""` or a JSON `null`, then returns the right side, + otherwise evaluates to the left side. +- Safe Access operator (`?.`) + - if the left side is `""` or a JSON `null`, then return `null`. Otherwise, + attempt to index. + - This can still cause an error to occur if the left hand side exists but is + not an object. + (`Number` or `String`). - conditionals (`condition ? 'value' : 'other value'`) - numbers, strings, booleans and variable references (`12`, `'hi'`, `true`, `some_variable`) - json access (`object.field`, `array[12]`, `object["field"]`)