runjucks_core/
globals.rs

1//! Nunjucks-style default globals (`range`, `cycler`, `joiner`) and marker values for `is callable`.
2//!
3//! See [`nunjucks/nunjucks/src/globals.js`](https://github.com/mozilla/nunjucks/blob/master/nunjucks/src/globals.js).
4
5use crate::errors::{Result, RunjucksError};
6use serde_json::{json, Map, Value};
7use std::collections::HashMap;
8
9/// Object key for built-in global function references (Nunjucks `typeof x === 'function'` parity for `is callable`).
10pub const RJ_BUILTIN: &str = "__runjucks_builtin";
11
12/// Cycler instance handle (`__runjucks_cycler`: index into [`crate::renderer::RenderState::cyclers`]).
13pub const RJ_CYCLER: &str = "__runjucks_cycler";
14
15/// Joiner instance handle (`__runjucks_joiner`: index into [`crate::renderer::RenderState::joiners`]).
16pub const RJ_JOINER: &str = "__runjucks_joiner";
17
18/// User `add_global` values that should be treated as callable in `is callable` tests.
19pub const RJ_CALLABLE: &str = "__runjucks_callable";
20
21/// Marker object for default globals `range`, `cycler`, `joiner` (variable lookup / callable test).
22pub fn builtin_marker(name: &str) -> Value {
23    let mut m = Map::new();
24    m.insert(RJ_BUILTIN.to_string(), Value::String(name.to_string()));
25    Value::Object(m)
26}
27
28pub fn default_globals_map() -> HashMap<String, Value> {
29    ["range", "cycler", "joiner"]
30        .into_iter()
31        .map(|n| (n.to_string(), builtin_marker(n)))
32        .collect()
33}
34
35/// `true` if `v` should be considered callable (built-in function markers, user `add_global` tag).
36/// Cycler/joiner **instances** are objects, not functions — Nunjucks `typeof` would be `object`.
37pub fn value_is_callable(v: &Value) -> bool {
38    match v {
39        Value::Object(o) => o.contains_key(RJ_BUILTIN) || o.contains_key(RJ_CALLABLE),
40        _ => false,
41    }
42}
43
44/// `true` if `v` is the default global marker for `expected` (`range`, `cycler`, or `joiner`).
45pub fn is_builtin_marker_value(v: &Value, expected: &str) -> bool {
46    match v {
47        Value::Object(o) => o
48            .get(RJ_BUILTIN)
49            .and_then(|x| x.as_str())
50            .map(|s| s == expected)
51            .unwrap_or(false),
52        _ => false,
53    }
54}
55
56fn as_f64(v: &Value) -> Result<f64> {
57    match v {
58        Value::Number(n) => n
59            .as_f64()
60            .ok_or_else(|| RunjucksError::new("invalid number in `range`")),
61        _ => Err(RunjucksError::new("`range` expects numeric arguments")),
62    }
63}
64
65/// Nunjucks `range(start, stop?, step?)` — see `globals.js`.
66pub fn builtin_range(args: &[Value]) -> Result<Value> {
67    let (start, stop, step) = match args.len() {
68        0 => {
69            return Err(RunjucksError::new("`range` expects at least one argument"));
70        }
71        1 => (0.0, as_f64(&args[0])?, 1.0),
72        2 => (as_f64(&args[0])?, as_f64(&args[1])?, 1.0),
73        3 => (
74            as_f64(&args[0])?,
75            as_f64(&args[1])?,
76            if args[2].is_null() {
77                1.0
78            } else {
79                as_f64(&args[2])?
80            },
81        ),
82        _ => {
83            return Err(RunjucksError::new(
84                "`range` expects at most three arguments",
85            ));
86        }
87    };
88
89    if step == 0.0 {
90        return Err(RunjucksError::new("`range` step cannot be zero"));
91    }
92
93    let mut out = Vec::new();
94    if step > 0.0 {
95        let mut i = start;
96        while i < stop {
97            out.push(json_num(i));
98            i += step;
99        }
100    } else {
101        let mut i = start;
102        while i > stop {
103            out.push(json_num(i));
104            i += step;
105        }
106    }
107    Ok(Value::Array(out))
108}
109
110fn json_num(x: f64) -> Value {
111    if x.fract() == 0.0 && x >= i64::MIN as f64 && x <= i64::MAX as f64 {
112        json!(x as i64)
113    } else {
114        json!(x)
115    }
116}
117
118/// State for one `cycler(...)` instance (Nunjucks `cycler` in `globals.js`).
119#[derive(Debug)]
120pub struct CyclerState {
121    pub items: Vec<Value>,
122    /// `-1` before any `next()`; then `0..items.len()-1` wrapping.
123    pos: isize,
124}
125
126impl CyclerState {
127    pub fn new(items: Vec<Value>) -> Self {
128        Self { items, pos: -1 }
129    }
130
131    /// Nunjucks `cycler(...).next()` — not `Iterator::next`.
132    #[allow(clippy::should_implement_trait)]
133    pub fn next(&mut self) -> Value {
134        if self.items.is_empty() {
135            return Value::Null;
136        }
137        self.pos += 1;
138        if self.pos >= self.items.len() as isize {
139            self.pos = 0;
140        }
141        self.items[self.pos as usize].clone()
142    }
143}
144
145/// State for one `joiner(sep?)` instance.
146#[derive(Debug)]
147pub struct JoinerState {
148    pub sep: String,
149    first: bool,
150}
151
152impl JoinerState {
153    pub fn new(sep: String) -> Self {
154        Self { sep, first: true }
155    }
156
157    pub fn invoke(&mut self) -> String {
158        if self.first {
159            self.first = false;
160            String::new()
161        } else {
162            self.sep.clone()
163        }
164    }
165}
166
167pub fn cycler_handle_value(id: usize) -> Value {
168    json!({ RJ_CYCLER: id })
169}
170
171pub fn joiner_handle_value(id: usize) -> Value {
172    json!({ RJ_JOINER: id })
173}
174
175pub fn parse_cycler_id(v: &Value) -> Option<usize> {
176    match v {
177        Value::Object(o) => o
178            .get(RJ_CYCLER)
179            .and_then(|x| x.as_u64())
180            .map(|x| x as usize),
181        _ => None,
182    }
183}
184
185pub fn parse_joiner_id(v: &Value) -> Option<usize> {
186    match v {
187        Value::Object(o) => o
188            .get(RJ_JOINER)
189            .and_then(|x| x.as_u64())
190            .map(|x| x as usize),
191        _ => None,
192    }
193}