Files
2026-10-21 21:01:23 -03:00

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));
}
}