Skip to content

Commit

Permalink
Safe Access Operator (#577)
Browse files Browse the repository at this point in the history
Co-authored-by: elkowar <5300871+elkowar@users.noreply.github.com>
  • Loading branch information
oldwomanjosiah and elkowar committed Oct 1, 2022
1 parent 91d55cb commit 37fc231
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 14 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions crates/simplexpr/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -43,7 +50,7 @@ pub enum SimplExpr {
BinOp(Span, Box<SimplExpr>, BinOp, Box<SimplExpr>),
UnaryOp(Span, UnaryOp, Box<SimplExpr>),
IfElse(Span, Box<SimplExpr>, Box<SimplExpr>, Box<SimplExpr>),
JsonAccess(Span, Box<SimplExpr>, Box<SimplExpr>),
JsonAccess(Span, AccessType, Box<SimplExpr>, Box<SimplExpr>),
FunctionCall(Span, String, Vec<SimplExpr>),
}

Expand All @@ -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(", "))
}
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
79 changes: 73 additions & 6 deletions crates/simplexpr/src/eval.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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::<Result<_, _>>()?)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()?))
Expand All @@ -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()?;
Expand All @@ -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)),
}
}
Expand Down Expand Up @@ -330,3 +345,55 @@ fn call_expr_function(name: &str, args: Vec<DynVal>) -> Result<DynVal, EvalError
_ => 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())),
}
}
3 changes: 3 additions & 0 deletions crates/simplexpr/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub enum Token {
GT,
LT,
Elvis,
SafeAccess,
RegexMatch,

Not,
Expand Down Expand Up @@ -88,6 +89,7 @@ regex_rules! {
r">" => |_| Token::GT,
r"<" => |_| Token::LT,
r"\?:" => |_| Token::Elvis,
r"\?\." => |_| Token::SafeAccess,
r"=~" => |_| Token::RegexMatch,

r"!" => |_| Token::Not,
Expand Down Expand Up @@ -318,5 +320,6 @@ mod test {
"${ {"hi": "ho"}.hi }".hi
"#),
empty_interpolation => v!(r#""${}""#),
safe_interpolation => v!(r#""${ { "key": "value" }.key1?.key2 ?: "Recovery" }""#),
}
}
1 change: 1 addition & 0 deletions crates/simplexpr/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"#,
);
}
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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"],
)
13 changes: 10 additions & 3 deletions crates/simplexpr/src/simplexpr_parser.lalrpop
Original file line number Diff line number Diff line change
@@ -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::*;
Expand All @@ -25,6 +25,7 @@ extern {
">" => Token::GT,
"<" => Token::LT,
"?:" => Token::Elvis,
"?." => Token::SafeAccess,
"=~" => Token::RegexMatch,

"!" => Token::Not,
Expand Down Expand Up @@ -75,10 +76,16 @@ pub Expr: SimplExpr = {

#[precedence(level="1")] #[assoc(side="right")]
<l:@L> <ident:"identifier"> "(" <args: Comma<ExprReset>> ")" <r:@R> => FunctionCall(Span(l, r, fid), ident, args),
<l:@L> <value:Expr> "[" <index: ExprReset> "]" <r:@R> => JsonAccess(Span(l, r, fid), b(value), b(index)),
<l:@L> <value:Expr> "[" <index: ExprReset> "]" <r:@R> => {
JsonAccess(Span(l, r, fid), AccessType::Normal, b(value), b(index))
},

<l:@L> <value:Expr> "." <lit_l:@L> <index:"identifier"> <r:@R> => {
JsonAccess(Span(l, r, fid), b(value), b(Literal(index.into())))
JsonAccess(Span(l, r, fid), AccessType::Normal, b(value), b(Literal(index.into())))
},

<l:@L> <value:Expr> "?." <lit_l:@L> <index:"identifier"> <r:@R> => {
JsonAccess(Span(l, r, fid), AccessType::Safe, b(value), b(Literal(index.into())))
},

#[precedence(level="2")] #[assoc(side="right")]
Expand Down
9 changes: 8 additions & 1 deletion docs/src/expression_language.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]`)
Expand Down

0 comments on commit 37fc231

Please sign in to comment.