180 lines
6.5 KiB
Rust
180 lines
6.5 KiB
Rust
//! The parser of the Bases expression language.
|
|
|
|
use notescraft_bases::Error;
|
|
use notescraft_bases::expr::{BinOp, Expr, UnOp, parse_expr};
|
|
|
|
/// A name.
|
|
fn ident(name: &str) -> Expr {
|
|
Expr::Ident(name.to_string())
|
|
}
|
|
|
|
/// A member access.
|
|
fn member(base: Expr, name: &str) -> Expr {
|
|
Expr::Member(Box::new(base), name.to_string())
|
|
}
|
|
|
|
/// An index access.
|
|
fn index(base: Expr, at: Expr) -> Expr {
|
|
Expr::Index(Box::new(base), Box::new(at))
|
|
}
|
|
|
|
/// A call.
|
|
fn call(callee: Expr, args: Vec<Expr>) -> Expr {
|
|
Expr::Call { callee: Box::new(callee), args }
|
|
}
|
|
|
|
/// A binary operation.
|
|
fn binary(op: BinOp, left: Expr, right: Expr) -> Expr {
|
|
Expr::Binary(op, Box::new(left), Box::new(right))
|
|
}
|
|
|
|
/// A prefix operation.
|
|
fn unary(op: UnOp, operand: Expr) -> Expr {
|
|
Expr::Unary(op, Box::new(operand))
|
|
}
|
|
|
|
/// A string literal.
|
|
fn text(value: &str) -> Expr {
|
|
Expr::Str(value.to_string())
|
|
}
|
|
|
|
/// Parses, or fails the test with the reason.
|
|
fn parse(source: &str) -> Expr {
|
|
parse_expr(source).unwrap_or_else(|err| panic!("`{source}` must parse: {err}"))
|
|
}
|
|
|
|
#[test]
|
|
fn precedence_follows_the_spec() {
|
|
let cases: Vec<(&str, Expr)> = vec![
|
|
("a || b && c", binary(BinOp::Or, ident("a"), binary(BinOp::And, ident("b"), ident("c")))),
|
|
("a && b || c", binary(BinOp::Or, binary(BinOp::And, ident("a"), ident("b")), ident("c"))),
|
|
("a == b && c != d", binary(BinOp::And, binary(BinOp::Eq, ident("a"), ident("b")), binary(BinOp::Ne, ident("c"), ident("d")))),
|
|
("a < b == c", binary(BinOp::Eq, binary(BinOp::Lt, ident("a"), ident("b")), ident("c"))),
|
|
("a + b * c", binary(BinOp::Add, ident("a"), binary(BinOp::Mul, ident("b"), ident("c")))),
|
|
("a * b + c", binary(BinOp::Add, binary(BinOp::Mul, ident("a"), ident("b")), ident("c"))),
|
|
("-a * b", binary(BinOp::Mul, unary(UnOp::Neg, ident("a")), ident("b"))),
|
|
("!a && b", binary(BinOp::And, unary(UnOp::Not, ident("a")), ident("b"))),
|
|
("a - b - c", binary(BinOp::Sub, binary(BinOp::Sub, ident("a"), ident("b")), ident("c"))),
|
|
("a / b % c", binary(BinOp::Mod, binary(BinOp::Div, ident("a"), ident("b")), ident("c"))),
|
|
("a + b == c + d", binary(BinOp::Eq, binary(BinOp::Add, ident("a"), ident("b")), binary(BinOp::Add, ident("c"), ident("d")))),
|
|
];
|
|
for (source, expected) in cases {
|
|
assert_eq!(parse(source), expected, "{source}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn member_index_and_call_chain() {
|
|
let expected = call(member(index(member(ident("a"), "b"), Expr::Num(0.0)), "c"), vec![text("x")]);
|
|
assert_eq!(parse("a.b[0].c(\"x\")"), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn lists_and_objects_are_literals() {
|
|
assert_eq!(parse("[latitude, longitude]"), Expr::Array(vec![ident("latitude"), ident("longitude")]));
|
|
assert_eq!(parse("{\"k\": 1, \"j\": \"v\"}"), Expr::Object(vec![("k".to_string(), Expr::Num(1.0)), ("j".to_string(), text("v"))]));
|
|
assert_eq!(parse("[]"), Expr::Array(Vec::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn comparison_and_boolean_operators() {
|
|
assert_eq!(
|
|
parse("price > 2.1 && status != \"done\""),
|
|
binary(BinOp::And, binary(BinOp::Gt, ident("price"), Expr::Num(2.1)), binary(BinOp::Ne, ident("status"), text("done")),)
|
|
);
|
|
assert_eq!(
|
|
parse("file.hasTag(\"a\") || !file.inFolder(\"x\")"),
|
|
binary(BinOp::Or, call(member(ident("file"), "hasTag"), vec![text("a")]), unary(UnOp::Not, call(member(ident("file"), "inFolder"), vec![text("x")])),)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_regex_is_a_literal_where_an_operand_is_expected() {
|
|
let regex = Expr::Regex { pattern: "ab".to_string(), flags: String::new() };
|
|
assert_eq!(parse("(/ab/)"), regex);
|
|
assert_eq!(parse("x == /ab/"), binary(BinOp::Eq, ident("x"), regex));
|
|
assert_eq!(parse("x == /ab/i"), binary(BinOp::Eq, ident("x"), Expr::Regex { pattern: "ab".to_string(), flags: "i".to_string() }));
|
|
}
|
|
|
|
#[test]
|
|
fn a_slash_after_an_operand_is_a_division() {
|
|
assert_eq!(parse("a / b"), binary(BinOp::Div, ident("a"), ident("b")));
|
|
assert_eq!(parse("a / b / c"), binary(BinOp::Div, binary(BinOp::Div, ident("a"), ident("b")), ident("c")));
|
|
}
|
|
|
|
#[test]
|
|
fn strings_know_their_escapes() {
|
|
assert_eq!(parse("\"a\\nb\""), text("a\nb"));
|
|
assert_eq!(parse("'a\\'b'"), text("a'b"));
|
|
assert_eq!(parse("\"a\\\\b\""), text("a\\b"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unterminated_string_reports_its_position() {
|
|
let err = parse_expr("\"abc").expect_err("the string is not closed");
|
|
match err {
|
|
Error::Syntax { line, column, message } => {
|
|
assert_eq!(line, 1);
|
|
assert_eq!(column, 1);
|
|
assert!(message.contains("not closed"), "{message}");
|
|
}
|
|
other @ (Error::Limit(_) | Error::Invalid(_) | Error::Schema { .. }) => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_deep_expression_is_refused() {
|
|
let source = format!("{}x{}", "(".repeat(65), ")".repeat(65));
|
|
let err = parse_expr(&source).expect_err("65 levels are too deep");
|
|
assert!(matches!(err, Error::Limit(_)), "unexpected error: {err:?}");
|
|
|
|
let source = format!("{}x{}", "(".repeat(64), ")".repeat(64));
|
|
assert!(parse_expr(&source).is_ok(), "64 levels are allowed");
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_expression_is_refused() {
|
|
let source = "a".repeat(4097);
|
|
let err = parse_expr(&source).expect_err("4097 bytes are too many");
|
|
assert!(matches!(err, Error::Limit(_)), "unexpected error: {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn deps_collect_the_namespaces() {
|
|
let expr = parse("note.title + file.size + formula.total + this.file.ctime + price");
|
|
let deps = expr.deps();
|
|
assert_eq!(deps.note, vec!["price".to_string(), "title".to_string()]);
|
|
assert_eq!(deps.file, vec!["size".to_string()]);
|
|
assert_eq!(deps.formula, vec!["total".to_string()]);
|
|
assert!(deps.uses_this);
|
|
}
|
|
|
|
#[test]
|
|
fn deps_keep_the_member_path() {
|
|
let deps = parse("note.meta.author").deps();
|
|
assert_eq!(deps.note, vec!["meta.author".to_string()]);
|
|
assert_eq!(deps.file, Vec::<String>::new());
|
|
assert_eq!(deps.formula, Vec::<String>::new());
|
|
assert!(!deps.uses_this);
|
|
}
|
|
|
|
/// The next value of a small linear congruential generator.
|
|
fn next_u64(state: &mut u64) -> u64 {
|
|
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
|
*state >> 33
|
|
}
|
|
|
|
#[test]
|
|
fn random_sources_never_panic() {
|
|
let mut state = 0x2545_f491_4f6c_dd1d_u64;
|
|
for _ in 0..5000 {
|
|
let len = usize::try_from(next_u64(&mut state) % 24).unwrap_or(0);
|
|
let mut bytes = Vec::new();
|
|
for _ in 0..len {
|
|
bytes.push(u8::try_from(next_u64(&mut state) % 256).unwrap_or(0));
|
|
}
|
|
let source = String::from_utf8_lossy(&bytes);
|
|
drop(parse_expr(&source));
|
|
}
|
|
}
|