Skip to main content

docgen_bases/
value.rs

1//! The typed value model shared by the evaluator, the function library, and the
2//! renderer. Mirrors Obsidian Bases' runtime types: null, boolean, number,
3//! string, date, duration, list, object, and link.
4//!
5//! The model is intentionally forgiving (Obsidian is): comparisons and coercions
6//! between mismatched types yield sensible defaults rather than errors, so a
7//! filter over notes with heterogeneous frontmatter never explodes.
8
9use std::cmp::Ordering;
10use std::collections::BTreeMap;
11
12/// A wikilink value: a target path plus an optional display label. Produced by
13/// the `link(...)` function, by frontmatter `[[wikilinks]]`, and by `file.asLink`.
14#[derive(Debug, Clone, PartialEq)]
15pub struct BaseLink {
16    /// The link target as written (e.g. `Categories/Books` or `Books`). The
17    /// `.md` extension and any `[[ ]]`/alias are already stripped.
18    pub path: String,
19    /// Optional display text (`link("path", "Display")` or `[[path|Display]]`).
20    pub display: Option<String>,
21}
22
23impl BaseLink {
24    pub fn new(path: impl Into<String>) -> Self {
25        Self {
26            path: path.into(),
27            display: None,
28        }
29    }
30
31    pub fn with_display(path: impl Into<String>, display: impl Into<String>) -> Self {
32        Self {
33            path: path.into(),
34            display: Some(display.into()),
35        }
36    }
37
38    /// The path's final segment without directories or extension — how Obsidian
39    /// compares links written as full paths vs bare names
40    /// (`link("Categories/Books")` matches a note named `Books`).
41    pub fn basename(&self) -> &str {
42        let no_dir = self.path.rsplit('/').next().unwrap_or(&self.path);
43        no_dir.strip_suffix(".md").unwrap_or(no_dir)
44    }
45
46    /// Two links refer to the same note if their basenames match (Obsidian resolves
47    /// short links by basename) OR one path is a suffix-path of the other.
48    pub fn same_target(&self, other: &BaseLink) -> bool {
49        if self.path == other.path {
50            return true;
51        }
52        self.basename().eq_ignore_ascii_case(other.basename())
53    }
54}
55
56/// A calendar date-time with millisecond resolution. Stored as broken-down fields
57/// plus a total-milliseconds value (epoch-relative, proleptic Gregorian) so date
58/// arithmetic and ordering are exact without pulling in a datetime dependency.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct BaseDate {
61    pub year: i64,
62    pub month: u32, // 1-12
63    pub day: u32,   // 1-31
64    pub hour: u32,
65    pub minute: u32,
66    pub second: u32,
67    pub millisecond: u32,
68    /// Whether the source carried a time component (drives default formatting:
69    /// a bare `2024-01-02` renders without the `00:00:00`).
70    pub has_time: bool,
71}
72
73impl BaseDate {
74    /// Days from the epoch (1970-01-01) to this date, via a well-known
75    /// civil-from-days algorithm (Howard Hinnant). Valid for the full proleptic
76    /// Gregorian range.
77    fn days_from_epoch(&self) -> i64 {
78        let y = if self.month <= 2 {
79            self.year - 1
80        } else {
81            self.year
82        };
83        let era = if y >= 0 { y } else { y - 399 } / 400;
84        let yoe = y - era * 400; // [0, 399]
85        let m = self.month as i64;
86        let d = self.day as i64;
87        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
88        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
89        era * 146097 + doe - 719468
90    }
91
92    /// Total milliseconds since the Unix epoch (UTC, no timezone handling — dates
93    /// in a static docs vault are naive). Used for ordering and subtraction.
94    pub fn epoch_millis(&self) -> i64 {
95        let days = self.days_from_epoch();
96        let secs =
97            days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64;
98        secs * 1000 + self.millisecond as i64
99    }
100}
101
102impl PartialOrd for BaseDate {
103    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
104        Some(self.epoch_millis().cmp(&other.epoch_millis()))
105    }
106}
107
108/// A runtime value in the Bases expression language.
109#[derive(Debug, Clone)]
110pub enum Value {
111    Null,
112    Bool(bool),
113    Number(f64),
114    Str(String),
115    Date(BaseDate),
116    /// A duration in milliseconds (from `duration(...)` or a date subtraction).
117    Duration(i64),
118    List(Vec<Value>),
119    Object(BTreeMap<String, Value>),
120    Link(BaseLink),
121}
122
123impl Value {
124    /// A short type name, for `isType(...)` and diagnostics.
125    pub fn type_name(&self) -> &'static str {
126        match self {
127            Value::Null => "null",
128            Value::Bool(_) => "boolean",
129            Value::Number(_) => "number",
130            Value::Str(_) => "string",
131            Value::Date(_) => "date",
132            Value::Duration(_) => "duration",
133            Value::List(_) => "list",
134            Value::Object(_) => "object",
135            Value::Link(_) => "link",
136        }
137    }
138
139    /// Obsidian truthiness: null / false / 0 / empty string / empty list / empty
140    /// object are falsey; everything else is truthy. Drives filters and `if(...)`.
141    pub fn is_truthy(&self) -> bool {
142        match self {
143            Value::Null => false,
144            Value::Bool(b) => *b,
145            Value::Number(n) => *n != 0.0 && !n.is_nan(),
146            Value::Str(s) => !s.is_empty(),
147            Value::Duration(d) => *d != 0,
148            Value::List(l) => !l.is_empty(),
149            Value::Object(o) => !o.is_empty(),
150            Value::Date(_) | Value::Link(_) => true,
151        }
152    }
153
154    /// Whether the value is "empty" for `isEmpty()` / the Filled/Empty summaries:
155    /// null, empty string, empty list, or empty object.
156    pub fn is_empty(&self) -> bool {
157        match self {
158            Value::Null => true,
159            Value::Str(s) => s.is_empty(),
160            Value::List(l) => l.is_empty(),
161            Value::Object(o) => o.is_empty(),
162            _ => false,
163        }
164    }
165
166    /// Best-effort numeric coercion (for arithmetic and `number(...)`). Booleans →
167    /// 0/1, numeric strings parse, durations → their millisecond count; otherwise
168    /// `None`.
169    pub fn as_number(&self) -> Option<f64> {
170        match self {
171            Value::Number(n) => Some(*n),
172            Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
173            Value::Str(s) => s.trim().parse::<f64>().ok(),
174            Value::Duration(d) => Some(*d as f64),
175            _ => None,
176        }
177    }
178
179    /// Coerce to a string the way an expression's `+` with a string operand would.
180    pub fn as_str_coerced(&self) -> String {
181        self.display()
182    }
183
184    /// The human-facing rendering used for table cells and string coercion.
185    /// Numbers drop a trailing `.0`; lists join with `, `; links show their
186    /// display or basename; dates use their default format.
187    pub fn display(&self) -> String {
188        match self {
189            Value::Null => String::new(),
190            Value::Bool(b) => b.to_string(),
191            Value::Number(n) => format_number(*n),
192            Value::Str(s) => s.clone(),
193            Value::Date(d) => crate::format::default_date(d),
194            Value::Duration(ms) => format!("{ms}ms"),
195            Value::List(items) => items
196                .iter()
197                .map(Value::display)
198                .collect::<Vec<_>>()
199                .join(", "),
200            Value::Object(map) => map
201                .iter()
202                .map(|(k, v)| format!("{k}: {}", v.display()))
203                .collect::<Vec<_>>()
204                .join(", "),
205            Value::Link(l) => l
206                .display
207                .clone()
208                .unwrap_or_else(|| l.basename().to_string()),
209        }
210    }
211}
212
213/// Format a number without a trailing `.0` for integers, keeping full precision
214/// otherwise. `3.0` → `"3"`, `3.14` → `"3.14"`.
215pub fn format_number(n: f64) -> String {
216    if n.is_nan() {
217        return "NaN".to_string();
218    }
219    if n.is_infinite() {
220        return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
221    }
222    if n == n.trunc() && n.abs() < 1e15 {
223        format!("{}", n as i64)
224    } else {
225        // Trim trailing zeros from the fractional part.
226        let s = format!("{n}");
227        s
228    }
229}
230
231impl Value {
232    /// Structural equality used by `==`/`!=` and `.contains(...)`. Numbers compare
233    /// numerically (incl. across numeric strings), links compare by target,
234    /// dates by instant; mismatched types are unequal (except numeric coercion).
235    pub fn loose_eq(&self, other: &Value) -> bool {
236        match (self, other) {
237            (Value::Null, Value::Null) => true,
238            (Value::Bool(a), Value::Bool(b)) => a == b,
239            (Value::Number(a), Value::Number(b)) => a == b,
240            (Value::Str(a), Value::Str(b)) => a == b,
241            (Value::Date(a), Value::Date(b)) => a.epoch_millis() == b.epoch_millis(),
242            (Value::Duration(a), Value::Duration(b)) => a == b,
243            (Value::Link(a), Value::Link(b)) => a.same_target(b),
244            // A link compared to a string matches by basename/path (so
245            // `categories.contains("Books")` works alongside `link("Books")`).
246            (Value::Link(a), Value::Str(b)) | (Value::Str(b), Value::Link(a)) => {
247                a.path == *b || a.basename().eq_ignore_ascii_case(b)
248            }
249            (Value::List(a), Value::List(b)) => {
250                a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.loose_eq(y))
251            }
252            // Cross-numeric (number vs numeric string / bool).
253            _ => match (self.as_number(), other.as_number()) {
254                (Some(a), Some(b))
255                    if matches!(self, Value::Number(_) | Value::Bool(_))
256                        || matches!(other, Value::Number(_) | Value::Bool(_)) =>
257                {
258                    a == b
259                }
260                _ => false,
261            },
262        }
263    }
264
265    /// Ordering for `<`/`>`/sorting. Numbers and dates order naturally; strings
266    /// lexicographically (case-insensitive, like Obsidian's column sort); other
267    /// combinations fall back to a stable type-name ordering so a sort never
268    /// panics on mixed columns. `Null` sorts last.
269    pub fn loose_cmp(&self, other: &Value) -> Ordering {
270        match (self, other) {
271            (Value::Null, Value::Null) => Ordering::Equal,
272            (Value::Null, _) => Ordering::Greater,
273            (_, Value::Null) => Ordering::Less,
274            (Value::Number(a), Value::Number(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
275            (Value::Date(a), Value::Date(b)) => a.epoch_millis().cmp(&b.epoch_millis()),
276            (Value::Duration(a), Value::Duration(b)) => a.cmp(b),
277            (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
278            (Value::Str(a), Value::Str(b)) => a.to_lowercase().cmp(&b.to_lowercase()),
279            (Value::Link(a), Value::Link(b)) => {
280                a.display().to_lowercase().cmp(&b.display().to_lowercase())
281            }
282            _ => {
283                // Try numeric coercion, else compare rendered strings.
284                match (self.as_number(), other.as_number()) {
285                    (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
286                    _ => self
287                        .display()
288                        .to_lowercase()
289                        .cmp(&other.display().to_lowercase()),
290                }
291            }
292        }
293    }
294}
295
296impl BaseLink {
297    fn display(&self) -> String {
298        self.display
299            .clone()
300            .unwrap_or_else(|| self.basename().to_string())
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn truthiness_matches_obsidian() {
310        assert!(!Value::Null.is_truthy());
311        assert!(!Value::Bool(false).is_truthy());
312        assert!(!Value::Number(0.0).is_truthy());
313        assert!(!Value::Str(String::new()).is_truthy());
314        assert!(!Value::List(vec![]).is_truthy());
315        assert!(Value::Number(1.0).is_truthy());
316        assert!(Value::Str("x".into()).is_truthy());
317        assert!(Value::List(vec![Value::Null]).is_truthy());
318    }
319
320    #[test]
321    fn number_formatting_drops_integer_decimal() {
322        assert_eq!(format_number(3.0), "3");
323        assert_eq!(format_number(3.25), "3.25");
324        assert_eq!(format_number(-5.0), "-5");
325    }
326
327    #[test]
328    fn link_basename_and_same_target() {
329        let a = BaseLink::new("Categories/Books");
330        let b = BaseLink::new("Books");
331        assert_eq!(a.basename(), "Books");
332        assert!(a.same_target(&b));
333        let c = BaseLink::with_display("Categories/Books", "Books");
334        assert_eq!(c.display, Some("Books".to_string()));
335    }
336
337    #[test]
338    fn link_string_loose_equality_by_basename() {
339        let link = Value::Link(BaseLink::new("Categories/Books"));
340        assert!(link.loose_eq(&Value::Str("Books".into())));
341        assert!(link.loose_eq(&Value::Str("Categories/Books".into())));
342        assert!(!link.loose_eq(&Value::Str("Films".into())));
343    }
344
345    #[test]
346    fn numeric_string_cross_equality() {
347        assert!(Value::Number(3.0).loose_eq(&Value::Str("3".into())));
348        assert!(!Value::Str("3".into()).loose_eq(&Value::Str("3.0".into())));
349    }
350
351    #[test]
352    fn epoch_millis_reference_dates() {
353        let epoch = BaseDate {
354            year: 1970,
355            month: 1,
356            day: 1,
357            hour: 0,
358            minute: 0,
359            second: 0,
360            millisecond: 0,
361            has_time: false,
362        };
363        assert_eq!(epoch.epoch_millis(), 0);
364        let d = BaseDate {
365            year: 2000,
366            month: 1,
367            day: 1,
368            hour: 0,
369            minute: 0,
370            second: 0,
371            millisecond: 0,
372            has_time: false,
373        };
374        // 2000-01-01 is 946684800 seconds after epoch.
375        assert_eq!(d.epoch_millis(), 946_684_800_000);
376    }
377
378    #[test]
379    fn date_ordering() {
380        let a = BaseDate {
381            year: 2020,
382            month: 5,
383            day: 1,
384            hour: 0,
385            minute: 0,
386            second: 0,
387            millisecond: 0,
388            has_time: false,
389        };
390        let b = BaseDate {
391            year: 2021,
392            month: 1,
393            day: 1,
394            hour: 0,
395            minute: 0,
396            second: 0,
397            millisecond: 0,
398            has_time: false,
399        };
400        assert_eq!(a.partial_cmp(&b), Some(Ordering::Less));
401    }
402
403    #[test]
404    fn null_sorts_last() {
405        assert_eq!(
406            Value::Null.loose_cmp(&Value::Number(1.0)),
407            Ordering::Greater
408        );
409        assert_eq!(Value::Number(1.0).loose_cmp(&Value::Null), Ordering::Less);
410    }
411}