//! Evaluator core: operators, laziness, formulas and the global, any, string, number and object
//! functions (normal, null and wrong-type case for each).
mod common;
use common::{TestRow, cases, ev, is_error, list, n, run, s};
use notescraft_bases::Value;
fn errors(sources: &[&str]) {
for src in sources {
assert!(is_error(&ev(src)), "{src} gave {:?}", ev(src));
}
}
#[test]
fn operators() {
cases(&[
("1 + 2 * 3", n(7.0)),
("(1 + 2) * 3", n(9.0)),
("7 % 4", n(3.0)),
("-(2)", n(-2.0)),
("!0", Value::Bool(true)),
("\"a\" + 1", s("a1")),
("1 + \"a\"", s("1a")),
("\"a\" + \"b\"", s("ab")),
("null + 1", Value::Null),
("1 < null", Value::Bool(false)),
("null == null", Value::Bool(true)),
("null != 1", Value::Bool(true)),
("\"a\" == \"a\"", Value::Bool(true)),
("1 == \"1\"", Value::Bool(false)),
("[1, 2] == [1, 2]", Value::Bool(true)),
("\"b\" > \"a\"", Value::Bool(true)),
("2 >= 2 && 1 <= 1", Value::Bool(true)),
("false && 1 / 0", Value::Bool(false)),
("true || 1 / 0", Value::Bool(true)),
("0 || \"x\"", Value::Bool(true)),
("date(\"2024-01-01\") < \"2024-02-01\"", Value::Bool(true)),
("date(\"2024-01-01\") == \"2024-01-01\"", Value::Bool(true)),
("(now() + \"1d\") - now()", n(86_400_000.0)),
("(date(\"2024-12-01\") + \"1M 4h 3m\").toString()", s("2025-01-01 04:03:00")),
("(date(\"2024-01-31\") + \"1M\").toString()", s("2024-02-29")),
("(date(\"2024-03-01\") - \"1d\").toString()", s("2024-02-29")),
("date(\"2024-03-02\") - date(\"2024-03-01\")", n(86_400_000.0)),
("(duration(\"5h\") * 2).toString()", s("10h")),
("(duration(\"1d\") + duration(\"2h\")).toString()", s("1d 2h")),
]);
errors(&["1 / 0", "5 % 0", "2 * duration(\"5h\")", "[1] + 1", "-\"a\"", "1 / 0 + 1", "date(\"2024-01-01\") + 3", "duration(\"1d\") * 1.5"]);
}
#[test]
fn if_is_lazy() {
cases(&[
("if(true, 1, 1 / 0)", n(1.0)),
("if(false, 1 / 0, 2)", n(2.0)),
("if(false, 1)", Value::Null),
("if(\"\", 1, 2)", n(2.0)),
("if([], 1, 2)", n(2.0)),
("if([0], 1, 2)", n(1.0)),
]);
errors(&["if(1 / 0, 1, 2)", "if(true)", "if(1, 2, 3, 4)"]);
}
#[test]
fn global_functions() {
cases(&[
("now().toString()", s("2024-12-01 12:00:00")),
("today().toString()", s("2024-12-01")),
("date(\"2024-12-01 04:03:00\").toString()", s("2024-12-01 04:03:00")),
("date(null)", Value::Null),
("duration(\"1M 4h\").toString()", s("1M 4h")),
("duration(null)", Value::Null),
("number(\"3.4\")", n(3.4)),
("number(true)", n(1.0)),
("number(false)", n(0.0)),
("number(date(\"1970-01-02\"))", n(86_400_000.0)),
("number(null)", Value::Null),
("min(3, 1, 2)", n(1.0)),
("max(3, 1, 2)", n(3.0)),
("max(null, 1)", Value::Null),
("list(\"a\")", list(vec![s("a")])),
("list([1, 2])", list(vec![n(1.0), n(2.0)])),
("list(null)", list(vec![])),
("link(\"[[Note|Shown]]\")", Value::Link { target: "Note".into(), display: "Shown".into() }),
("link(\"Note\", \"X\")", Value::Link { target: "Note".into(), display: "X".into() }),
("link(file)", Value::Link { target: "Folder/Note.md".into(), display: String::new() }),
("link(null)", Value::Null),
("escapeHTML(\"&'\")", s("<a href="x">&'</a>")),
("escapeHTML(null)", Value::Null),
("html(\"x\")", Value::Html("x".into())),
("image(\"a.png\")", Value::Image("a.png".into())),
("image(file)", Value::Image("Folder/Note.md".into())),
("icon(\"arrow-right\")", Value::Icon("arrow-right".into())),
]);
errors(&[
"date(\"nope\")",
"date(1)",
"duration(\"x\")",
"duration(1)",
"number(\"x\")",
"number([1])",
"min()",
"max(\"a\", 1)",
"link(1)",
"escapeHTML(1)",
"html(1)",
"icon(1)",
"now(1)",
"nosuch(1)",
]);
}
#[test]
fn random_is_seeded_and_in_range() {
let row = TestRow::new("a.md");
let one = run("random()", &row, &[], None, None);
assert_eq!(one, run("random()", &row, &[], None, None));
assert!(matches!(one, Value::Number(x) if (0.0..1.0).contains(&x)));
assert_ne!(run("random()", &TestRow::new("b.md"), &[], None, None), one);
assert_ne!(run("[random(), random()][0]", &row, &[], None, None), run("[random(), random()][1]", &row, &[], None, None));
}
#[test]
fn any_functions() {
cases(&[
("1.isTruthy()", Value::Bool(true)),
("(0).isTruthy()", Value::Bool(false)),
("\"\".isTruthy()", Value::Bool(false)),
("null.isTruthy()", Value::Bool(false)),
("[].isTruthy()", Value::Bool(false)),
("\"example\".isType(\"string\")", Value::Bool(true)),
("true.isType(\"boolean\")", Value::Bool(true)),
("null.isType(\"null\")", Value::Bool(true)),
("[1].isType(\"string\")", Value::Bool(false)),
("123.toString()", s("123")),
("(1.5).toString()", s("1.5")),
("null.toString()", s("")),
("[1, \"a\"].toString()", s("1, a")),
("true.toString()", s("true")),
]);
errors(&["1.isType(2)", "1.isTruthy(1)"]);
}
#[test]
fn string_functions() {
cases(&[
("\"hello\".length", n(5.0)),
("\"hello\".contains(\"ell\")", Value::Bool(true)),
("\"hello\".contains(\"x\")", Value::Bool(false)),
("\"hello\".containsAll(\"h\", \"e\")", Value::Bool(true)),
("\"hello\".containsAll(\"h\", \"x\")", Value::Bool(false)),
("\"hello\".containsAny(\"x\", \"y\", \"e\")", Value::Bool(true)),
("\"hello\".containsAny(\"x\")", Value::Bool(false)),
("\"hello\".endsWith(\"lo\")", Value::Bool(true)),
("\"hello\".startsWith(\"he\")", Value::Bool(true)),
("\"hello\".startsWith(\"lo\")", Value::Bool(false)),
("\"\".isEmpty()", Value::Bool(true)),
("\"Hello world\".isEmpty()", Value::Bool(false)),
("null.isEmpty()", Value::Bool(true)),
("\"HeLLo\".lower()", s("hello")),
("\"a:b:c:d\".replace(\":\", \"-\")", s("a-b-c-d")),
("\"123\".repeat(2)", s("123123")),
("\"123\".repeat(0)", s("")),
("\"hello\".reverse()", s("olleh")),
("\"hello\".slice(1, 4)", s("ell")),
("\"hello\".slice(2)", s("llo")),
("\"hello\".slice(-3)", s("llo")),
("\"hello\".slice(3, 1)", s("")),
("\"a,b,c,d\".split(\",\", 3)", list(vec![s("a"), s("b"), s("c")])),
("\"a,b\".split(\",\")", list(vec![s("a"), s("b")])),
("\"ab\".split(\"\")", list(vec![s("a"), s("b")])),
("\"hello world\".title()", s("Hello World")),
("\" hi \".trim()", s("hi")),
("null.lower()", Value::Null),
("null.trim()", Value::Null),
("\"héllo\".length", n(5.0)),
("\"abc\"[1]", s("b")),
]);
errors(&[
"\"a\".contains(1)",
"\"a\".contains()",
"\"a\".repeat(-1)",
"\"a\".repeat(\"x\")",
"\"a\".repeat(2000000)",
"\"a\".replace(1, 2)",
"\"a\".slice(\"x\")",
"\"a\".split(1)",
"\"a\".nosuch()",
"\"a\".lower(1)",
]);
}
#[test]
fn number_functions() {
cases(&[
("(-5).abs()", n(5.0)),
("(2.1).ceil()", n(3.0)),
("(2.9).floor()", n(2.0)),
("(2.5).round()", n(3.0)),
("(2.3333).round(2)", n(2.33)),
("(3.14159).toFixed(2)", s("3.14")),
("(3).toFixed(0)", s("3")),
("(5).isEmpty()", Value::Bool(false)),
("null.abs()", Value::Null),
("null.toFixed(2)", Value::Null),
]);
errors(&["(1).toFixed(-1)", "(1).toFixed(101)", "(1).toFixed(\"x\")", "(1).round(99)", "(1).abs(1)", "\"a\".abs()"]);
}
#[test]
fn object_functions() {
cases(&[
("({\"a\": 1, \"b\": 2}).keys()", list(vec![s("a"), s("b")])),
("({\"a\": 1, \"b\": 2}).values()", list(vec![n(1.0), n(2.0)])),
("({}).isEmpty()", Value::Bool(true)),
("({\"a\": 1}).isEmpty()", Value::Bool(false)),
("({\"a\": 1}).a", n(1.0)),
("({\"a\": 1}).b", Value::Null),
("({\"a\": 1})[\"a\"]", n(1.0)),
("null.keys()", Value::Null),
]);
errors(&["({}).keys(1)", "({}).nosuch()"]);
}
fn book() -> TestRow {
let mut row = TestRow::new("Books/Dune.md").prop("price", n(10.0)).prop("age", n(4.0)).prop("x y", s("spaced")).tags(&["#book"]);
row.size = 42;
row
}
#[test]
fn properties_and_file_fields() {
let row = book();
let at = |src: &str| run(src, &row, &[], None, None);
assert_eq!(at("price"), n(10.0));
assert_eq!(at("note.price"), n(10.0));
assert_eq!(at("note[\"x y\"]"), s("spaced"));
assert_eq!(at("missing"), Value::Null);
assert_eq!(at("missing + 1"), Value::Null);
assert_eq!(at("file.name"), s("Dune.md"));
assert_eq!(at("file.basename"), s("Dune"));
assert_eq!(at("file.ext"), s("md"));
assert_eq!(at("file.folder"), s("Books"));
assert_eq!(at("file.path"), s("Books/Dune.md"));
assert_eq!(at("file.size"), n(42.0));
assert_eq!(at("file.tags"), list(vec![s("#book")]));
assert!(is_error(&at("file.nosuch")));
assert!(is_error(&at("formula")));
assert_eq!(at("this"), Value::Null);
assert_eq!(at("this.file"), Value::Null);
}
#[test]
fn this_points_at_the_given_row() {
let row = book();
let this = TestRow::new("Embed/Host.md").prop("title", s("Host"));
let at = |src: &str| run(src, &row, &[], None, Some(&this));
assert_eq!(at("this.file.name"), s("Host.md"));
assert_eq!(at("this.title"), s("Host"));
assert_eq!(at("this.nothing"), Value::Null);
assert_eq!(at("this == this"), Value::Bool(true));
assert_eq!(at("file.name"), s("Dune.md"));
}
#[test]
fn doc_formulas_ppu_and_formatted_price() {
let formulas = [("formatted_price", "if(price, price.toFixed(2) + \" dollars\")"), ("ppu", "(price / age).toFixed(2)")];
let row = book();
assert_eq!(run("formula.ppu", &row, &formulas, None, None), s("2.50"));
assert_eq!(run("formula.formatted_price", &row, &formulas, None, None), s("10.00 dollars"));
assert_eq!(run("formula[\"ppu\"]", &row, &formulas, None, None), s("2.50"));
let no_price = TestRow::new("a.md").prop("age", n(4.0));
assert_eq!(run("formula.formatted_price", &no_price, &formulas, None, None), Value::Null);
assert_eq!(run("formula.ppu", &no_price, &formulas, None, None), Value::Null);
let zero_age = TestRow::new("a.md").prop("price", n(1.0)).prop("age", n(0.0));
assert_eq!(run("formula.ppu", &zero_age, &formulas, None, None), Value::Error("division by zero".into()));
}
#[test]
fn formula_cycles_unknowns_and_bad_sources() {
let formulas = [("a", "formula.b + 1"), ("b", "formula.a"), ("c", "formula.a"), ("d", "1 + 1"), ("self", "formula.self"), ("bad", "1 +")];
let row = TestRow::new("a.md");
let at = |src: &str| run(src, &row, &formulas, None, None);
for name in ["formula.a", "formula.b", "formula.self"] {
assert!(matches!(at(name), Value::Error(m) if m.contains("cycle")), "{name}");
}
assert!(matches!(at("formula.a"), Value::Error(m) if m == "formula cycle: a -> b -> a"));
assert!(is_error(&at("formula.c")));
assert_eq!(at("formula.d"), n(2.0));
assert!(matches!(at("formula.bad"), Value::Error(m) if m.contains("syntax")));
assert!(matches!(at("formula.nope"), Value::Error(m) if m.contains("unknown formula")));
}
#[test]
fn formulas_are_memoised_and_depth_is_bounded() {
let doubling: Vec<(String, String)> =
(0..60).map(|i| (format!("f{i}"), if i == 0 { "1".to_string() } else { format!("formula.f{} + formula.f{}", i - 1, i - 1) })).collect();
let borrowed: Vec<(&str, &str)> = doubling.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
let row = TestRow::new("a.md");
assert!(matches!(run("formula.f59", &row, &borrowed, None, None), Value::Number(x) if x > 5e17));
let chain: Vec<(String, String)> = (0..200).map(|i| (format!("g{i}"), if i == 0 { "1".to_string() } else { format!("formula.g{} + 1", i - 1) })).collect();
let borrowed: Vec<(&str, &str)> = chain.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
assert_eq!(run("formula.g50", &row, &borrowed, None, None), n(51.0));
assert!(is_error(&run("formula.g199", &row, &borrowed, None, None)));
}
#[test]
fn errors_propagate_and_never_panic() {
errors(&["(1 / 0) + 1", "[1 / 0]", "({\"a\": 1 / 0})", "(1 / 0).toString()", "\"a\".contains(1 / 0)", "max(1, 1 / 0)"]);
let deep = format!("{}1{}", "(".repeat(60), ")".repeat(60));
assert_eq!(ev(&deep), n(1.0));
}