Skip to main content

docgen_bases/
eval.rs

1//! The tree-walking evaluator. Turns an [`Expr`] into a [`Value`] against an
2//! [`EvalCtx`] (the current note, the corpus, and the base's formulas). It never
3//! panics: unknown identifiers, type mismatches, and out-of-range indices all
4//! resolve to [`Value::Null`], matching Obsidian's forgiving evaluation.
5
6use std::cell::{Cell, RefCell};
7use std::collections::BTreeMap;
8
9use crate::ast::{BinaryOp, Expr, UnaryOp};
10use crate::functions;
11use crate::note::{Corpus, Note};
12use crate::value::{BaseDate, Value};
13
14/// Everything an expression needs to evaluate: the note it's evaluated against,
15/// the whole corpus (for backlinks/link resolution), the base's formula
16/// definitions, and an optional `this` context note (the note a base is embedded
17/// in). Formula evaluation is memoized per note with a cycle guard.
18pub struct EvalCtx<'a> {
19    pub note: &'a Note,
20    pub corpus: &'a Corpus,
21    pub this: Option<&'a Note>,
22    /// Formula name → parsed expression (shared across the whole base).
23    pub formulas: &'a BTreeMap<String, Expr>,
24    /// Formula names currently being evaluated (cycle guard).
25    active: RefCell<Vec<String>>,
26    /// Memoized formula results for this note.
27    cache: RefCell<BTreeMap<String, Value>>,
28    /// Current evaluation recursion depth (guards against a stack overflow from a
29    /// deeply nested AST — e.g. a long member chain the parser built iteratively).
30    depth: Cell<usize>,
31}
32
33/// Maximum evaluation recursion depth. A deeper AST resolves to `Null` rather than
34/// overflowing the stack. Matches the parser's nesting guard in spirit; the
35/// parser's node budget keeps ASTs well under this in practice.
36pub const MAX_EVAL_DEPTH: usize = 512;
37
38impl<'a> EvalCtx<'a> {
39    pub fn new(note: &'a Note, corpus: &'a Corpus, formulas: &'a BTreeMap<String, Expr>) -> Self {
40        Self {
41            note,
42            corpus,
43            this: None,
44            formulas,
45            active: RefCell::new(Vec::new()),
46            cache: RefCell::new(BTreeMap::new()),
47            depth: Cell::new(0),
48        }
49    }
50
51    pub fn with_this(mut self, this: Option<&'a Note>) -> Self {
52        self.this = this;
53        self
54    }
55
56    /// Evaluate a named formula against this context's note, memoized, with a
57    /// cycle guard (a self-referential formula yields `Null`).
58    pub fn eval_formula(&self, name: &str) -> Value {
59        if let Some(v) = self.cache.borrow().get(name) {
60            return v.clone();
61        }
62        if self.active.borrow().iter().any(|n| n == name) {
63            return Value::Null; // cycle
64        }
65        let Some(expr) = self.formulas.get(name) else {
66            return Value::Null;
67        };
68        self.active.borrow_mut().push(name.to_string());
69        let v = self.eval(expr);
70        self.active.borrow_mut().pop();
71        self.cache.borrow_mut().insert(name.to_string(), v.clone());
72        v
73    }
74
75    /// Evaluate an expression to a value. Depth-guarded: a pathologically deep AST
76    /// resolves to `Null` past [`MAX_EVAL_DEPTH`] rather than overflowing the stack.
77    pub fn eval(&self, expr: &Expr) -> Value {
78        let d = self.depth.get();
79        if d >= MAX_EVAL_DEPTH {
80            return Value::Null;
81        }
82        self.depth.set(d + 1);
83        let v = self.eval_inner(expr);
84        self.depth.set(d);
85        v
86    }
87
88    fn eval_inner(&self, expr: &Expr) -> Value {
89        match expr {
90            Expr::Null => Value::Null,
91            Expr::Bool(b) => Value::Bool(*b),
92            Expr::Number(n) => Value::Number(*n),
93            Expr::Str(s) => Value::Str(s.clone()),
94            Expr::Regex(p, f) => Value::Str(format!("/{p}/{f}")), // regex used only via .matches
95            Expr::Ident(name) => self.ident(name),
96            Expr::Member(obj, name) => self.member(obj, name),
97            Expr::Index(obj, idx) => self.index(obj, idx),
98            Expr::Call(callee, args) => self.call(callee, args),
99            Expr::Unary(op, e) => self.unary(*op, e),
100            Expr::Binary(op, l, r) => self.binary(*op, l, r),
101        }
102    }
103
104    /// A bare identifier is a note property (the namespace markers `file`/`note`/
105    /// `formula`/`this` only appear as the object of a member access, handled in
106    /// [`member`]).
107    fn ident(&self, name: &str) -> Value {
108        match name {
109            // A lone namespace word has no value on its own.
110            "file" | "note" | "formula" => Value::Null,
111            "this" => self
112                .this
113                .map(|n| Value::Object(n.properties.clone()))
114                .unwrap_or(Value::Null),
115            _ => self.note.note_property(name),
116        }
117    }
118
119    fn member(&self, obj: &Expr, name: &str) -> Value {
120        // Namespace member accesses resolve against metadata, not a value.
121        if let Expr::Ident(ns) = obj {
122            match ns.as_str() {
123                "file" => return self.note.file_property(name),
124                "note" => return self.note.note_property(name),
125                "formula" => return self.eval_formula(name),
126                "this" => {
127                    let this = self.this.unwrap_or(self.note);
128                    // `this.file` yields the file namespace; handled when the next
129                    // member is accessed. Here `this.<prop>` is a note property.
130                    if name == "file" {
131                        // Represent `this.file` as the note's file properties object;
132                        // `this.file.name` then indexes it below in `index`/member.
133                        return Value::Object(file_object(this));
134                    }
135                    return this.note_property(name);
136                }
137                _ => {}
138            }
139        }
140        // `this.file.<field>` — object produced above.
141        let recv = self.eval(obj);
142        value_member(&recv, name)
143    }
144
145    fn index(&self, obj: &Expr, idx: &Expr) -> Value {
146        let recv = self.eval(obj);
147        let key = self.eval(idx);
148        match (&recv, &key) {
149            (Value::List(items), Value::Number(n)) => match int_index(*n, items.len()) {
150                Some(i) => items[i].clone(),
151                None => Value::Null,
152            },
153            (Value::Object(map), Value::Str(k)) => map.get(k).cloned().unwrap_or(Value::Null),
154            (Value::Str(s), Value::Number(n)) => {
155                let chars: Vec<char> = s.chars().collect();
156                match int_index(*n, chars.len()) {
157                    Some(i) => Value::Str(chars[i].to_string()),
158                    None => Value::Null,
159                }
160            }
161            _ => Value::Null,
162        }
163    }
164
165    fn call(&self, callee: &Expr, args: &[Expr]) -> Value {
166        match callee {
167            // Global function call: `link(...)`, `date(...)`, `if(...)`, `[a,b]`.
168            Expr::Ident(name) => functions::global_call(name, args, self),
169            // Method call. Namespace-object methods (`file.hasTag(...)`) dispatch
170            // against metadata; otherwise evaluate the receiver and dispatch.
171            Expr::Member(obj, method) => {
172                if let Expr::Ident(ns) = &**obj {
173                    match ns.as_str() {
174                        "file" => return functions::file_method(self.note, method, args, self),
175                        "this" => {
176                            let this = self.this.unwrap_or(self.note);
177                            return functions::file_method(this, method, args, self);
178                        }
179                        _ => {}
180                    }
181                }
182                // `this.file.hasLink(...)` — obj is `this.file` (a member).
183                if let Expr::Member(inner, f) = &**obj {
184                    if matches!(&**inner, Expr::Ident(n) if n == "this") && f == "file" {
185                        let this = self.this.unwrap_or(self.note);
186                        return functions::file_method(this, method, args, self);
187                    }
188                }
189                let recv = self.eval(obj);
190                functions::method_call(&recv, method, args, self)
191            }
192            _ => Value::Null,
193        }
194    }
195
196    fn unary(&self, op: UnaryOp, e: &Expr) -> Value {
197        let v = self.eval(e);
198        match op {
199            UnaryOp::Not => Value::Bool(!v.is_truthy()),
200            UnaryOp::Neg => v
201                .as_number()
202                .map(|n| Value::Number(-n))
203                .unwrap_or(Value::Null),
204        }
205    }
206
207    fn binary(&self, op: BinaryOp, l: &Expr, r: &Expr) -> Value {
208        // Short-circuit boolean operators.
209        match op {
210            BinaryOp::And => {
211                let lv = self.eval(l);
212                return if !lv.is_truthy() {
213                    Value::Bool(false)
214                } else {
215                    Value::Bool(self.eval(r).is_truthy())
216                };
217            }
218            BinaryOp::Or => {
219                let lv = self.eval(l);
220                return if lv.is_truthy() {
221                    Value::Bool(true)
222                } else {
223                    Value::Bool(self.eval(r).is_truthy())
224                };
225            }
226            _ => {}
227        }
228        let lv = self.eval(l);
229        let rv = self.eval(r);
230        match op {
231            BinaryOp::Eq => Value::Bool(lv.loose_eq(&rv)),
232            BinaryOp::NotEq => Value::Bool(!lv.loose_eq(&rv)),
233            BinaryOp::Lt => Value::Bool(lv.loose_cmp(&rv).is_lt()),
234            BinaryOp::Gt => Value::Bool(lv.loose_cmp(&rv).is_gt()),
235            BinaryOp::LtEq => Value::Bool(lv.loose_cmp(&rv).is_le()),
236            BinaryOp::GtEq => Value::Bool(lv.loose_cmp(&rv).is_ge()),
237            BinaryOp::Add => add(&lv, &rv),
238            BinaryOp::Sub => sub(&lv, &rv),
239            BinaryOp::Mul => arith(&lv, &rv, |a, b| a * b),
240            BinaryOp::Div => arith(&lv, &rv, |a, b| a / b),
241            BinaryOp::Mod => arith(&lv, &rv, |a, b| a % b),
242            BinaryOp::And | BinaryOp::Or => unreachable!(),
243        }
244    }
245}
246
247/// Build the `file.*` property object (for `this.file.<field>`).
248fn file_object(note: &Note) -> BTreeMap<String, Value> {
249    let mut m = BTreeMap::new();
250    for f in [
251        "name", "basename", "path", "folder", "ext", "size", "ctime", "mtime", "tags", "links",
252    ] {
253        m.insert(f.to_string(), note.file_property(f));
254    }
255    m
256}
257
258/// Resolve a numeric index into a collection of length `len`, applying Obsidian's
259/// rules: the index must be a finite integer (a fractional or NaN index is
260/// invalid → `None`); a negative index counts from the end; out-of-range → `None`.
261fn int_index(n: f64, len: usize) -> Option<usize> {
262    if !n.is_finite() || n.fract() != 0.0 {
263        return None;
264    }
265    let i = n as i64;
266    let len_i = len as i64;
267    let i = if i < 0 { len_i + i } else { i };
268    if i >= 0 && i < len_i {
269        Some(i as usize)
270    } else {
271        None
272    }
273}
274
275/// Member access on a *value* (not a namespace): list/string `.length`, date
276/// fields, object keys, link `.path`/`.display`.
277pub fn value_member(recv: &Value, name: &str) -> Value {
278    match recv {
279        Value::List(items) => match name {
280            "length" => Value::Number(items.len() as f64),
281            _ => Value::Null,
282        },
283        Value::Str(s) => match name {
284            "length" => Value::Number(s.chars().count() as f64),
285            _ => Value::Null,
286        },
287        Value::Object(map) => map.get(name).cloned().unwrap_or(Value::Null),
288        Value::Date(d) => date_field(d, name),
289        Value::Link(l) => match name {
290            "path" => Value::Str(l.path.clone()),
291            "display" => l.display.clone().map(Value::Str).unwrap_or(Value::Null),
292            _ => Value::Null,
293        },
294        _ => Value::Null,
295    }
296}
297
298fn date_field(d: &BaseDate, name: &str) -> Value {
299    match name {
300        "year" => Value::Number(d.year as f64),
301        "month" => Value::Number(d.month as f64),
302        "day" => Value::Number(d.day as f64),
303        "hour" => Value::Number(d.hour as f64),
304        "minute" => Value::Number(d.minute as f64),
305        "second" => Value::Number(d.second as f64),
306        "millisecond" => Value::Number(d.millisecond as f64),
307        _ => Value::Null,
308    }
309}
310
311/// `+`: numeric when both coerce to numbers, date±duration handled in [`add`],
312/// otherwise string concatenation (matching Obsidian's `"a" + b`).
313fn add(l: &Value, r: &Value) -> Value {
314    // date + duration → date.
315    if let (Value::Date(d), Value::Duration(ms)) = (l, r) {
316        return Value::Date(add_duration(d, *ms));
317    }
318    if let (Value::Duration(ms), Value::Date(d)) = (l, r) {
319        return Value::Date(add_duration(d, *ms));
320    }
321    if let (Value::Duration(a), Value::Duration(b)) = (l, r) {
322        return Value::Duration(a + b);
323    }
324    // If either side is a string, concatenate string renderings.
325    if matches!(l, Value::Str(_)) || matches!(r, Value::Str(_)) {
326        return Value::Str(format!("{}{}", l.as_str_coerced(), r.as_str_coerced()));
327    }
328    match (l.as_number(), r.as_number()) {
329        (Some(a), Some(b)) => Value::Number(a + b),
330        _ => Value::Str(format!("{}{}", l.as_str_coerced(), r.as_str_coerced())),
331    }
332}
333
334/// `-`: date−duration → date, date−date → duration (ms), else numeric.
335fn sub(l: &Value, r: &Value) -> Value {
336    if let (Value::Date(d), Value::Duration(ms)) = (l, r) {
337        return Value::Date(add_duration(d, -*ms));
338    }
339    if let (Value::Date(a), Value::Date(b)) = (l, r) {
340        return Value::Duration(a.epoch_millis() - b.epoch_millis());
341    }
342    if let (Value::Duration(a), Value::Duration(b)) = (l, r) {
343        return Value::Duration(a - b);
344    }
345    arith(l, r, |a, b| a - b)
346}
347
348fn arith(l: &Value, r: &Value, f: impl Fn(f64, f64) -> f64) -> Value {
349    match (l.as_number(), r.as_number()) {
350        (Some(a), Some(b)) => Value::Number(f(a, b)),
351        _ => Value::Null,
352    }
353}
354
355/// Add `ms` milliseconds to a date by converting to epoch-millis and back.
356pub fn add_duration(d: &BaseDate, ms: i64) -> BaseDate {
357    let total = d.epoch_millis() + ms;
358    date_from_epoch_millis(total, d.has_time || ms % 86_400_000 != 0)
359}
360
361/// Convert epoch-millis back to a broken-down [`BaseDate`] (inverse of
362/// `epoch_millis`, via the civil-from-days algorithm).
363pub fn date_from_epoch_millis(ms: i64, has_time: bool) -> BaseDate {
364    let mut days = ms.div_euclid(86_400_000);
365    let mut rem = ms.rem_euclid(86_400_000);
366    let millisecond = (rem % 1000) as u32;
367    rem /= 1000;
368    let second = (rem % 60) as u32;
369    rem /= 60;
370    let minute = (rem % 60) as u32;
371    rem /= 60;
372    let hour = (rem % 24) as u32;
373    // civil_from_days (Howard Hinnant).
374    days += 719468;
375    let era = if days >= 0 { days } else { days - 146096 } / 146097;
376    let doe = days - era * 146097; // [0, 146096]
377    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
378    let y = yoe + era * 400;
379    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
380    let mp = (5 * doy + 2) / 153; // [0, 11]
381    let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
382    let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
383    let year = if month <= 2 { y + 1 } else { y };
384    BaseDate {
385        year,
386        month,
387        day,
388        hour,
389        minute,
390        second,
391        millisecond,
392        has_time,
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::note::Corpus;
400    use crate::parser::parse;
401    use crate::value::BaseLink;
402
403    fn eval_str(src: &str, note: &Note) -> Value {
404        let corpus = Corpus::new(vec![note.clone()]);
405        let formulas = BTreeMap::new();
406        let ctx = EvalCtx::new(note, &corpus, &formulas);
407        ctx.eval(&parse(src).unwrap())
408    }
409
410    fn note_with(props: &[(&str, Value)]) -> Note {
411        let mut n = Note::default();
412        for (k, v) in props {
413            n.properties.insert(k.to_string(), v.clone());
414        }
415        n
416    }
417
418    #[test]
419    fn arithmetic_and_precedence() {
420        let n = Note::default();
421        assert!(matches!(eval_str("1 + 2 * 3", &n), Value::Number(x) if x == 7.0));
422        assert!(matches!(eval_str("(1 + 2) * 3", &n), Value::Number(x) if x == 9.0));
423        assert!(matches!(eval_str("10 % 3", &n), Value::Number(x) if x == 1.0));
424    }
425
426    #[test]
427    fn comparisons_and_logic() {
428        let n = note_with(&[("age", Value::Number(30.0))]);
429        assert!(matches!(eval_str("age > 18", &n), Value::Bool(true)));
430        assert!(matches!(
431            eval_str("age > 18 && age < 40", &n),
432            Value::Bool(true)
433        ));
434        assert!(matches!(
435            eval_str("age < 18 || age > 25", &n),
436            Value::Bool(true)
437        ));
438        assert!(matches!(eval_str("!(age > 18)", &n), Value::Bool(false)));
439    }
440
441    #[test]
442    fn bare_and_note_property() {
443        let n = note_with(&[("type", Value::Str("home".into()))]);
444        assert!(matches!(
445            eval_str(r#"type == "home""#, &n),
446            Value::Bool(true)
447        ));
448        assert!(matches!(
449            eval_str(r#"note.type == "home""#, &n),
450            Value::Bool(true)
451        ));
452    }
453
454    #[test]
455    fn file_properties() {
456        let mut n = Note::default();
457        n.name = "Intro.md".into();
458        n.ext = "md".into();
459        n.folder = "guide".into();
460        assert!(matches!(
461            eval_str(r#"file.ext == "md""#, &n),
462            Value::Bool(true)
463        ));
464        assert!(matches!(
465            eval_str(r#"file.name == "Intro.md""#, &n),
466            Value::Bool(true)
467        ));
468    }
469
470    #[test]
471    fn string_concat_and_number_coercion() {
472        let n = note_with(&[("price", Value::Number(3.0))]);
473        assert!(matches!(eval_str(r#""$" + price"#, &n), Value::Str(s) if s == "$3"));
474    }
475
476    #[test]
477    fn list_index_and_length() {
478        let n = note_with(&[(
479            "cats",
480            Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
481        )]);
482        assert!(matches!(eval_str("cats[0]", &n), Value::Str(s) if s == "a"));
483        assert!(matches!(eval_str("cats[-1]", &n), Value::Str(s) if s == "b"));
484        assert!(matches!(eval_str("cats.length", &n), Value::Number(x) if x == 2.0));
485    }
486
487    #[test]
488    fn string_negative_index_counts_from_end() {
489        let n = note_with(&[("title", Value::Str("abc".into()))]);
490        assert!(matches!(eval_str("title[-1]", &n), Value::Str(s) if s == "c"));
491        assert!(matches!(eval_str("title[0]", &n), Value::Str(s) if s == "a"));
492        assert!(matches!(eval_str("title[5]", &n), Value::Null));
493    }
494
495    #[test]
496    fn fractional_and_nan_index_are_null() {
497        let n = note_with(&[(
498            "cats",
499            Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
500        )]);
501        assert!(matches!(eval_str("cats[1.9]", &n), Value::Null));
502        // 0/0 → NaN index → Null (not element 0).
503        assert!(matches!(eval_str("cats[0 / 0]", &n), Value::Null));
504    }
505
506    #[test]
507    fn deep_member_chain_eval_does_not_overflow() {
508        // A member chain under the parser's node budget still parses, but evaluating
509        // it recurses deeper than MAX_EVAL_DEPTH — the eval guard must return Null
510        // rather than overflow the stack.
511        let n = Note::default();
512        let corpus = Corpus::new(vec![n.clone()]);
513        let formulas = BTreeMap::new();
514        let ctx = EvalCtx::new(&n, &corpus, &formulas);
515        let expr = format!("a{}", ".a".repeat(2000)); // 2001 nodes < 4096 budget
516        let parsed = crate::parser::parse(&expr).expect("under node budget → parses");
517        assert!(matches!(ctx.eval(&parsed), Value::Null));
518    }
519
520    #[test]
521    fn date_field_access() {
522        let n = note_with(&[(
523            "created",
524            Value::Date(crate::note::parse_date("2024-05-06").unwrap()),
525        )]);
526        assert!(matches!(eval_str("created.year", &n), Value::Number(x) if x == 2024.0));
527        assert!(matches!(eval_str("created.month", &n), Value::Number(x) if x == 5.0));
528    }
529
530    #[test]
531    fn date_minus_date_is_duration() {
532        let n = note_with(&[
533            (
534                "a",
535                Value::Date(crate::note::parse_date("2024-01-02").unwrap()),
536            ),
537            (
538                "b",
539                Value::Date(crate::note::parse_date("2024-01-01").unwrap()),
540            ),
541        ]);
542        // One day in ms.
543        assert!(matches!(eval_str("a - b", &n), Value::Duration(ms) if ms == 86_400_000));
544    }
545
546    #[test]
547    fn unknown_symbols_are_null_not_panic() {
548        let n = Note::default();
549        assert!(matches!(eval_str("nonexistent", &n), Value::Null));
550        assert!(matches!(eval_str("nope.deep.field", &n), Value::Null));
551        assert!(matches!(eval_str("missing[3]", &n), Value::Null));
552    }
553
554    #[test]
555    fn formula_evaluation_with_cache() {
556        let n = note_with(&[("price", Value::Number(10.0)), ("age", Value::Number(2.0))]);
557        let corpus = Corpus::new(vec![n.clone()]);
558        let mut formulas = BTreeMap::new();
559        formulas.insert("ppu".to_string(), parse("price / age").unwrap());
560        let ctx = EvalCtx::new(&n, &corpus, &formulas);
561        assert!(matches!(ctx.eval(&parse("formula.ppu").unwrap()), Value::Number(x) if x == 5.0));
562    }
563
564    #[test]
565    fn formula_cycle_yields_null() {
566        let n = Note::default();
567        let corpus = Corpus::new(vec![n.clone()]);
568        let mut formulas = BTreeMap::new();
569        formulas.insert("a".to_string(), parse("formula.b").unwrap());
570        formulas.insert("b".to_string(), parse("formula.a").unwrap());
571        let ctx = EvalCtx::new(&n, &corpus, &formulas);
572        assert!(matches!(
573            ctx.eval(&parse("formula.a").unwrap()),
574            Value::Null
575        ));
576    }
577
578    #[test]
579    fn link_contains_via_method() {
580        let n = note_with(&[(
581            "categories",
582            Value::List(vec![Value::Link(BaseLink::new("Categories/Books"))]),
583        )]);
584        assert!(matches!(
585            eval_str(
586                r#"categories.contains(link("Categories/Books", "Books"))"#,
587                &n
588            ),
589            Value::Bool(true)
590        ));
591        assert!(matches!(
592            eval_str(r#"categories.contains(link("Books"))"#, &n),
593            Value::Bool(true)
594        ));
595    }
596
597    #[test]
598    fn round_trip_epoch_conversion() {
599        let d = crate::note::parse_date("2024-05-06 07:08:09").unwrap();
600        let back = date_from_epoch_millis(d.epoch_millis(), true);
601        assert_eq!((back.year, back.month, back.day), (2024, 5, 6));
602        assert_eq!((back.hour, back.minute, back.second), (7, 8, 9));
603    }
604}