rustlavel_view/value.rs
1//! The view's rules for reading a `Json` value: truth, order, and text.
2//!
3//! There is deliberately no second value type here. Templates render whatever
4//! the rest of the framework already speaks — `rustlavel_core::Json` — so a
5//! query result or an API payload can go straight into a view without a
6//! conversion layer in between. What lives in this module is only the handful
7//! of decisions a template engine has to make on top of that model.
8
9use rustlavel_core::Json;
10use std::cmp::Ordering;
11
12/// Whether a value counts as true for `@if` and `&&`/`||`.
13///
14/// The rules follow PHP closely enough that a Laravel developer's instinct is
15/// right: nothing, zero, empty text, and an empty collection are all falsy.
16/// That is what makes `@if(items)` read naturally instead of forcing
17/// `@if(items.length > 0)`.
18pub fn truthy(value: &Json) -> bool {
19 match value {
20 Json::Null => false,
21 Json::Bool(flag) => *flag,
22 // NaN is falsy for the same reason it is not equal to itself: no
23 // comparison involving it should quietly succeed.
24 Json::Number(number) => *number != 0.0 && !number.is_nan(),
25 Json::String(text) => !text.is_empty(),
26 Json::Array(items) => !items.is_empty(),
27 Json::Object(map) => !map.is_empty(),
28 }
29}
30
31/// Ordering for `<`, `<=`, `>` and `>=`.
32///
33/// Only numbers and strings are ordered, and only against their own kind:
34/// comparing a number to a string is a mistake in the view, and answering
35/// `false` to both `a < b` and `a > b` is a far better clue than inventing a
36/// coercion rule the author has to memorise.
37pub fn compare(left: &Json, right: &Json) -> Option<Ordering> {
38 match (left, right) {
39 (Json::Number(a), Json::Number(b)) => a.partial_cmp(b),
40 (Json::String(a), Json::String(b)) => Some(a.cmp(b)),
41 _ => None,
42 }
43}
44
45/// Render a value as the text `{{ }}` would emit, before escaping.
46///
47/// `null` becomes the empty string. That is the single most important line in
48/// this file: a missing key must leave a hole in the page, never take the page
49/// down.
50pub fn to_text(value: &Json) -> String {
51 match value {
52 Json::Null => String::new(),
53 Json::Bool(flag) => flag.to_string(),
54 Json::String(text) => text.clone(),
55 // Ids are the most common numeric output, so integral values render
56 // without the `.0` that `f64` would otherwise insist on.
57 Json::Number(number) if number.fract() == 0.0 && number.abs() < 1e15 => {
58 (*number as i64).to_string()
59 }
60 Json::Number(number) => number.to_string(),
61 // Arrays and objects have no obvious text form; compact JSON is at
62 // least honest about what the author asked for.
63 other => other.to_string(),
64 }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
72 fn emptiness_of_every_kind_is_falsy() {
73 assert!(!truthy(&Json::Null));
74 assert!(!truthy(&Json::Bool(false)));
75 assert!(!truthy(&Json::Number(0.0)));
76 assert!(!truthy(&Json::from("")));
77 assert!(!truthy(&Json::Array(Vec::new())));
78 assert!(!truthy(&Json::object(Vec::<(String, Json)>::new())));
79
80 assert!(truthy(&Json::Bool(true)));
81 assert!(truthy(&Json::Number(-1.0)));
82 assert!(truthy(&Json::from("0")));
83 assert!(truthy(&Json::from(vec![1])));
84 }
85
86 #[test]
87 fn only_matching_kinds_are_ordered() {
88 assert_eq!(compare(&Json::from(1), &Json::from(2)), Some(Ordering::Less));
89 assert_eq!(compare(&Json::from("a"), &Json::from("b")), Some(Ordering::Less));
90 assert_eq!(compare(&Json::from(1), &Json::from("1")), None);
91 }
92
93 #[test]
94 fn null_renders_as_nothing_and_integers_keep_their_shape() {
95 assert_eq!(to_text(&Json::Null), "");
96 assert_eq!(to_text(&Json::from(42)), "42");
97 assert_eq!(to_text(&Json::from(1.5)), "1.5");
98 assert_eq!(to_text(&Json::Bool(true)), "true");
99 assert_eq!(to_text(&Json::from(vec![1, 2])), "[1,2]");
100 }
101}