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