//! Tests of the expression parser. use notescraft_templater::{Error, Expr, MAX_EXPRESSION_BYTES, MAX_EXPRESSION_DEPTH, PathPart, parse_expr}; /// The expressions of the D3 table of the spec. const D3_EXPRESSIONS: &[&str] = &[ "tp.date.now(\"YYYY-MM-DD\", 0)", "tp.date.now(\"YYYY-MM-DD\", 7, \"2026-10-13\", \"YYYY-MM-DD\")", "tp.date.tomorrow(\"YYYY-MM-DD\")", "tp.date.yesterday(\"YYYY-MM-DD\")", "tp.date.weekday(\"YYYY-MM-DD\", 0)", "tp.file.title", "tp.file.folder(true)", "tp.file.path(false)", "tp.file.tags", "tp.file.content", "tp.file.creation_date(\"YYYY-MM-DD\")", "tp.file.last_modified_date(\"YYYY-MM-DD\")", "tp.file.cursor(1)", "tp.file.cursor_append(\"x\")", "tp.file.selection()", "tp.file.include(\"[[Note#Heading]]\")", "tp.file.rename(\"name\")", "tp.file.move(\"path\")", "tp.file.exists(\"path\")", "tp.system.prompt(\"Project name\")", "tp.system.prompt(\"text\", \"default\", false, false, false)", "tp.system.suggester([\"a\", \"b\"], [\"1\", \"2\"])", "tp.system.clipboard()", "tp.frontmatter.project", "tp.frontmatter[\"my key\"]", "file.name.upper()", "now()", ]; /// The expression that is expected to parse. fn expr(src: &str) -> Expr { match parse_expr(src) { Ok(expr) => expr, Err(err) => panic!("parse failed: {err}"), } } /// The unsupported construct of an expression that is expected to be refused. fn unsupported(src: &str) -> String { match parse_expr(src) { Err(Error::Unsupported { what }) => what, other => panic!("expected an unsupported expression, got {other:?}"), } } /// A path of names. fn path(names: &[&str]) -> Expr { Expr::Path(names.iter().map(|name| PathPart::Ident((*name).to_owned())).collect()) } #[test] fn every_expression_of_the_d3_table_parses() { for src in D3_EXPRESSIONS { assert!(parse_expr(src).is_ok(), "{src} does not parse"); } } #[test] fn a_path_is_a_list_of_parts() { assert_eq!(expr("tp.file.title"), path(&["tp", "file", "title"])); } #[test] fn a_quoted_key_is_an_index_part() { assert_eq!( expr("tp.frontmatter[\"my key\"]"), Expr::Path(vec![PathPart::Ident("tp".to_owned()), PathPart::Ident("frontmatter".to_owned()), PathPart::Index("my key".to_owned()),]) ); } #[test] fn a_call_keeps_its_path_and_its_arguments() { assert_eq!( expr("tp.date.now(\"YYYY-MM-DD\", 7)"), Expr::Call { path: vec!["tp".to_owned(), "date".to_owned(), "now".to_owned()], args: vec![Expr::Str("YYYY-MM-DD".to_owned()), Expr::Num(7.0)] } ); } #[test] fn a_call_without_arguments_has_no_argument() { assert_eq!(expr("tp.system.clipboard()"), Expr::Call { path: vec!["tp".to_owned(), "system".to_owned(), "clipboard".to_owned()], args: Vec::new() }); } #[test] fn literals_parse() { assert_eq!(expr("true"), Expr::Bool(true)); assert_eq!(expr("false"), Expr::Bool(false)); assert_eq!(expr("null"), Expr::Null); assert_eq!(expr("42"), Expr::Num(42.0)); assert_eq!(expr("-7"), Expr::Num(-7.0)); assert_eq!(expr("1.5"), Expr::Num(1.5)); assert_eq!(expr("\"text\""), Expr::Str("text".to_owned())); assert_eq!(expr("'text'"), Expr::Str("text".to_owned())); assert_eq!(expr("`text`"), Expr::Str("text".to_owned())); } #[test] fn escapes_are_read() { assert_eq!(expr("\"a\\nb\\tc\\\\d\\\"e\\'f\""), Expr::Str("a\nb\tc\\d\"e'f".to_owned())); } #[test] fn an_array_literal_parses() { assert_eq!(expr("[\"a\", \"b\"]"), Expr::Array(vec![Expr::Str("a".to_owned()), Expr::Str("b".to_owned())])); } #[test] fn a_group_is_transparent() { assert_eq!(expr("(1 + 2)"), expr("1 + 2")); } #[test] fn addition_and_coalesce_build_a_tree() { assert_eq!(expr("a + b"), Expr::Add(Box::new(path(&["a"])), Box::new(path(&["b"])))); assert_eq!(expr("a ?? b"), Expr::Coalesce(Box::new(path(&["a"])), Box::new(path(&["b"])))); assert_eq!(expr("a + b ?? c"), Expr::Coalesce(Box::new(expr("a + b")), Box::new(path(&["c"])))); assert_eq!(expr("a + b + c"), Expr::Add(Box::new(expr("a + b")), Box::new(path(&["c"])))); } #[test] fn a_template_literal_substitution_is_unsupported() { assert_eq!(unsupported("`a${b}`"), "template literal substitution"); } #[test] fn a_ternary_is_unsupported() { assert_eq!(unsupported("a ? b : c"), "ternary"); } #[test] fn an_arrow_function_is_unsupported() { assert_eq!(unsupported("x => y"), "arrow function"); } #[test] fn a_chained_call_is_unsupported() { assert_eq!(unsupported("date(now()).format(\"YYYY\")"), "chained call"); } #[test] fn a_javascript_keyword_is_unsupported() { assert_eq!(unsupported("new Date()"), "the `new` keyword"); } #[test] fn a_logical_operator_is_unsupported() { assert_eq!(unsupported("a && b"), "logical operator"); } #[test] fn nesting_deeper_than_the_limit_is_refused() { let ok = format!("{}1{}", "(".repeat(MAX_EXPRESSION_DEPTH), ")".repeat(MAX_EXPRESSION_DEPTH)); assert!(parse_expr(&ok).is_ok(), "the limit depth must parse"); let deep = format!("{}1{}", "(".repeat(MAX_EXPRESSION_DEPTH + 1), ")".repeat(MAX_EXPRESSION_DEPTH + 1)); match parse_expr(&deep) { Err(Error::ExpressionTooDeep) => {} other => panic!("expected a too deep error, got {other:?}"), } } #[test] fn an_expression_over_two_kibibytes_is_refused() { let src = "a".repeat(MAX_EXPRESSION_BYTES + 1); match parse_expr(&src) { Err(Error::ExpressionTooLarge) => {} other => panic!("expected a too large error, got {other:?}"), } let ok = "a".repeat(MAX_EXPRESSION_BYTES); assert!(parse_expr(&ok).is_ok(), "the limit length must parse"); } #[test] fn a_malformed_expression_is_an_error() { for src in ["", "tp.", "tp.file.title +", "\"unclosed", "tp.file.title extra", "[1, 2", "tp.system.prompt("] { match parse_expr(src) { Err(Error::Syntax { .. }) => {} other => panic!("expected a syntax error for {src}, got {other:?}"), } } }