Skip to main content

rustlavel_view/
context.rs

1//! The data handed to a template, and the lookup rules on top of it.
2
3use rustlavel_core::Json;
4use std::collections::BTreeMap;
5
6/// The variables a single render can see.
7///
8/// Not to be confused with `rustlavel_core::Context`, which is the
9/// application's long-lived typed state. This one is per-render, cheap, and
10/// intentionally untyped: a view is the one place where the shape of the data
11/// is decided by whoever is editing the HTML.
12///
13/// ```
14/// use rustlavel_core::Json;
15/// use rustlavel_view::Context;
16///
17/// let context = Context::new()
18///     .with("title", "Dashboard")
19///     .with("user", Json::object([("name", Json::from("Ada"))]));
20///
21/// assert_eq!(context.get("title").unwrap(), &Json::from("Dashboard"));
22/// ```
23#[derive(Debug, Clone, Default, PartialEq)]
24pub struct Context {
25    data: BTreeMap<String, Json>,
26}
27
28impl Context {
29    pub fn new() -> Self {
30        Context::default()
31    }
32
33    /// Add a variable, chaining: `Context::new().with("user", user)`.
34    pub fn with(mut self, key: impl Into<String>, value: impl Into<Json>) -> Self {
35        self.insert(key, value);
36        self
37    }
38
39    /// Add a variable to an existing context.
40    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<Json>) {
41        self.data.insert(key.into(), value.into());
42    }
43
44    pub fn get(&self, key: &str) -> Option<&Json> {
45        self.data.get(key)
46    }
47
48    /// Turn a JSON object into a context, so a handler that already built one
49    /// does not have to take it apart again. Anything else yields an empty
50    /// context — a template's top level is a set of named variables.
51    pub fn from_json(value: Json) -> Self {
52        match value {
53            Json::Object(map) => Context { data: map.into_iter().collect() },
54            _ => Context::default(),
55        }
56    }
57}
58
59/// The context plus the local bindings `@foreach` introduces.
60///
61/// Locals are a stack rather than a map because shadowing is the behaviour
62/// nested loops need: an inner `loop` (or a rebound item) must hide the outer
63/// one for exactly as long as its body runs, then reveal it again.
64pub struct Scope<'a> {
65    context: &'a Context,
66    locals: Vec<(String, Json)>,
67}
68
69impl<'a> Scope<'a> {
70    pub fn new(context: &'a Context) -> Self {
71        Scope { context, locals: Vec::new() }
72    }
73
74    /// Bind a name for the duration of a block.
75    pub fn push(&mut self, name: impl Into<String>, value: Json) {
76        self.locals.push((name.into(), value));
77    }
78
79    /// Drop the most recent binding.
80    pub fn pop(&mut self) {
81        self.locals.pop();
82    }
83
84    /// Resolve a dotted path such as `user.address.city` or `items.0.title`.
85    ///
86    /// Only the first segment can be a local; the rest walks the value itself,
87    /// which is why array indices work the same way they do in `Json::get`.
88    pub fn lookup(&self, path: &str) -> Option<&Json> {
89        let (root, rest) = match path.split_once('.') {
90            Some((root, rest)) => (root, Some(rest)),
91            None => (path, None),
92        };
93
94        let value = self
95            .locals
96            .iter()
97            .rev()
98            .find(|(name, _)| name == root)
99            .map(|(_, value)| value)
100            .or_else(|| self.context.get(root))?;
101
102        match rest {
103            Some(rest) => value.get(rest),
104            None => Some(value),
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn resolves_dotted_paths_into_nested_data() {
115        let context = Context::new().with(
116            "user",
117            Json::object([
118                ("name", Json::from("Ada")),
119                ("tags", Json::from(vec!["math", "engines"])),
120            ]),
121        );
122        let scope = Scope::new(&context);
123
124        assert_eq!(scope.lookup("user.name"), Some(&Json::from("Ada")));
125        assert_eq!(scope.lookup("user.tags.1"), Some(&Json::from("engines")));
126        assert!(scope.lookup("user.missing").is_none());
127        assert!(scope.lookup("nobody").is_none());
128    }
129
130    #[test]
131    fn a_local_shadows_the_context_until_it_is_popped() {
132        let context = Context::new().with("item", "outer");
133        let mut scope = Scope::new(&context);
134
135        scope.push("item", Json::from("inner"));
136        assert_eq!(scope.lookup("item"), Some(&Json::from("inner")));
137
138        scope.pop();
139        assert_eq!(scope.lookup("item"), Some(&Json::from("outer")));
140    }
141
142    #[test]
143    fn a_json_object_becomes_the_whole_context() {
144        let context = Context::from_json(Json::object([("count", Json::from(3))]));
145
146        assert_eq!(context.get("count"), Some(&Json::from(3)));
147        assert_eq!(Context::from_json(Json::from(1)), Context::new());
148    }
149}