Files
Marcos Paulo 86abc0c219
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: task status and toggle, Templater expression parser, decor.list/decor.set (MiniMax M3.1 Flash)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 08:12:19 -03:00

187 lines
5.9 KiB
Rust

//! 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:?}"),
}
}
}