runjucks_core/
render_common.rs

1//! Shared pure-computation helpers used by both the synchronous [`crate::renderer`]
2//! and the asynchronous [`crate::async_renderer`] (when the `async` feature is enabled).
3//!
4//! These functions have no `&mut` state dependencies and can be called from any context.
5
6use crate::ast::{CompareOp, Expr};
7use crate::errors::{Result, RunjucksError};
8use crate::value::is_undefined_value;
9use serde_json::{json, Map, Value};
10use std::borrow::Cow;
11use std::cmp::Ordering;
12use std::sync::Arc;
13
14use crate::globals::{
15    builtin_range, cycler_handle_value, is_builtin_marker_value, joiner_handle_value,
16    CyclerState, JoinerState,
17};
18
19use ahash::AHashMap;
20
21/// `{% extends %}` parent expression plus block name → AST bodies.
22pub type ExtendsLayout = (Expr, std::collections::HashMap<String, Vec<crate::ast::Node>>);
23
24/// Nunjucks truthiness: `null`, `false`, `0`/`NaN`, and `""` are falsy.
25pub fn is_truthy(v: &Value) -> bool {
26    if is_undefined_value(v) {
27        return false;
28    }
29    match v {
30        Value::Null | Value::Bool(false) => false,
31        Value::Bool(true) => true,
32        Value::Number(n) => n.as_f64().map(|x| x != 0.0 && !x.is_nan()).unwrap_or(true),
33        Value::String(s) => !s.is_empty(),
34        Value::Array(_) | Value::Object(_) => true,
35    }
36}
37
38/// Comparison operators.
39pub fn compare_values(left: &Value, op: CompareOp, right: &Value) -> bool {
40    match op {
41        CompareOp::Eq | CompareOp::StrictEq => left == right,
42        CompareOp::Ne | CompareOp::StrictNe => left != right,
43        CompareOp::Lt => json_partial_cmp(left, right) == Some(Ordering::Less),
44        CompareOp::Gt => json_partial_cmp(left, right) == Some(Ordering::Greater),
45        CompareOp::Le => matches!(
46            json_partial_cmp(left, right),
47            Some(Ordering::Less | Ordering::Equal)
48        ),
49        CompareOp::Ge => matches!(
50            json_partial_cmp(left, right),
51            Some(Ordering::Greater | Ordering::Equal)
52        ),
53    }
54}
55
56/// Partial ordering for numbers and strings.
57pub fn json_partial_cmp(a: &Value, b: &Value) -> Option<Ordering> {
58    match (a, b) {
59        (Value::Number(x), Value::Number(y)) => {
60            let xf = x.as_f64()?;
61            let yf = y.as_f64()?;
62            xf.partial_cmp(&yf)
63        }
64        (Value::String(x), Value::String(y)) => Some(x.cmp(y)),
65        _ => None,
66    }
67}
68
69/// Numeric coercion: number, string parse, bool → 0/1.
70pub fn as_number(v: &Value) -> Option<f64> {
71    match v {
72        Value::Number(n) => n.as_f64(),
73        Value::String(s) => s.parse().ok(),
74        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
75        _ => None,
76    }
77}
78
79/// Handles `+` operator: numeric add or string concat.
80pub fn add_like_js(a: &Value, b: &Value) -> Value {
81    if let (Some(x), Some(y)) = (as_number(a), as_number(b)) {
82        json_num(x + y)
83    } else {
84        Value::String(format!(
85            "{}{}",
86            crate::value::value_to_string(a),
87            crate::value::value_to_string(b)
88        ))
89    }
90}
91
92/// Converts float to int if no fractional part.
93pub fn json_num(x: f64) -> Value {
94    if x.fract() == 0.0 && x >= i64::MIN as f64 && x <= i64::MAX as f64 {
95        json!(x as i64)
96    } else {
97        json!(x)
98    }
99}
100
101/// Implements membership test: array element, string substring, object key.
102pub fn eval_in(key: &Value, container: &Value) -> Result<bool> {
103    match container {
104        Value::Array(a) => Ok(a.iter().any(|v| v == key)),
105        Value::String(s) => {
106            let frag = match key {
107                Value::String(k) => k.as_str(),
108                _ => return Ok(false),
109            };
110            Ok(s.contains(frag))
111        }
112        Value::Object(o) => match key {
113            Value::String(k) => Ok(o.contains_key(k)),
114            _ => Ok(false),
115        },
116        _ => Err(RunjucksError::new(
117            "Cannot use \"in\" operator to search in unexpected type",
118        )),
119    }
120}
121
122/// Right-hand side of `is`: identifier, string/null literal, or call (`equalto(3)`).
123pub fn is_test_parts(e: &Expr) -> Option<(&str, &[Expr])> {
124    match e {
125        Expr::Variable(n) => Some((n.as_str(), &[])),
126        Expr::Literal(Value::String(s)) => Some((s.as_str(), &[])),
127        Expr::Literal(Value::Null) => Some(("null", &[])),
128        Expr::Call {
129            callee,
130            args,
131            kwargs,
132        } => {
133            if !kwargs.is_empty() {
134                return None;
135            }
136            if let Expr::Variable(n) = callee.as_ref() {
137                Some((n.as_str(), args.as_slice()))
138            } else {
139                None
140            }
141        }
142        _ => None,
143    }
144}
145
146/// Jinja-compat slice (`nunjucks` `sliceLookup`).
147pub fn jinja_slice_array(
148    obj: &[Value],
149    start: Option<i64>,
150    stop: Option<i64>,
151    step: Option<i64>,
152) -> Vec<Value> {
153    let len = obj.len() as i64;
154    let step = step.unwrap_or(1);
155    if step == 0 {
156        return vec![];
157    }
158    let mut start = start;
159    let mut stop = stop;
160    if start.is_none() {
161        start = Some(if step < 0 { (len - 1).max(0) } else { 0 });
162    }
163    if stop.is_none() {
164        stop = Some(if step < 0 { -1 } else { len });
165    } else if let Some(s) = stop {
166        if s < 0 {
167            stop = Some(s + len);
168        }
169    }
170    if let Some(s) = start {
171        if s < 0 {
172            start = Some(s + len);
173        }
174    }
175    let start = start.unwrap_or(0);
176    let stop = stop.unwrap_or(len);
177    let mut results = Vec::new();
178    let mut i = start;
179    loop {
180        if i < 0 || i > len {
181            break;
182        }
183        if step > 0 && i >= stop {
184            break;
185        }
186        if step < 0 && i <= stop {
187            break;
188        }
189        if let Some(item) = obj.get(i as usize) {
190            results.push(item.clone());
191        }
192        i += step;
193    }
194    results
195}
196
197/// Iteration abstraction for `for` loops.
198pub enum Iterable {
199    Rows(Vec<Value>),
200    Pairs(Vec<(String, Value)>),
201}
202
203/// Converts a `Value` to an `Iterable`.
204pub fn iterable_from_value(v: Value) -> Iterable {
205    match v {
206        Value::Null => Iterable::Rows(vec![]),
207        Value::Array(a) => Iterable::Rows(a),
208        Value::Object(o) => {
209            let mut keys: Vec<String> = o.keys().cloned().collect();
210            keys.sort();
211            let pairs: Vec<(String, Value)> = keys
212                .into_iter()
213                .map(|k| {
214                    let val = o.get(&k).cloned().unwrap_or(Value::Null);
215                    (k, val)
216                })
217                .collect();
218            Iterable::Pairs(pairs)
219        }
220        _ => Iterable::Rows(vec![]),
221    }
222}
223
224/// Checks if an iterable is empty.
225pub fn iterable_empty(it: &Iterable) -> bool {
226    match it {
227        Iterable::Rows(a) => a.is_empty(),
228        Iterable::Pairs(p) => p.is_empty(),
229    }
230}
231
232/// Fills a `loop` variable object for `for` loops.
233pub fn fill_loop_object(m: &mut Map<String, Value>, i: usize, len: usize) {
234    m.insert("index".to_string(), Value::Number(((i + 1) as u64).into()));
235    m.insert("index0".to_string(), Value::Number((i as u64).into()));
236    m.insert("first".to_string(), Value::Bool(i == 0));
237    m.insert("last".to_string(), Value::Bool(len > 0 && i + 1 == len));
238    m.insert("length".to_string(), Value::Number((len as u64).into()));
239    m.insert(
240        "revindex".to_string(),
241        Value::Number((len.saturating_sub(i) as u64).into()),
242    );
243    m.insert(
244        "revindex0".to_string(),
245        Value::Number((len.saturating_sub(1).saturating_sub(i) as u64).into()),
246    );
247}
248
249/// Reuses the same `loop` object map in the innermost frame when possible.
250pub fn inject_loop(frames: &mut Vec<AHashMap<String, Arc<Value>>>, i: usize, len: usize) {
251    let inner = frames
252        .last_mut()
253        .expect("inject_loop requires an active frame");
254    match inner.get_mut("loop") {
255        Some(arc) => match Arc::make_mut(arc) {
256            Value::Object(m) => fill_loop_object(m, i, len),
257            _ => {
258                let mut m = Map::with_capacity(7);
259                fill_loop_object(&mut m, i, len);
260                *arc = Arc::new(Value::Object(m));
261            }
262        },
263        None => {
264            let mut m = Map::with_capacity(7);
265            fill_loop_object(&mut m, i, len);
266            inner.insert("loop".to_string(), Arc::new(Value::Object(m)));
267        }
268    }
269    // Note: caller must bump_revision() after calling this
270}
271
272/// Checks if a name can be dispatched as a builtin function.
273pub fn can_dispatch_builtin_check(is_defined: bool, binding: Option<&Value>, name: &str) -> bool {
274    matches!(name, "range" | "cycler" | "joiner")
275        && (!is_defined
276            || binding
277                .map(|v| is_builtin_marker_value(v, name))
278                .unwrap_or(false))
279}
280
281/// Dispatches builtin function calls (`range`, `cycler`, `joiner`).
282pub fn try_dispatch_builtin(
283    cyclers: &mut Vec<CyclerState>,
284    joiners: &mut Vec<JoinerState>,
285    is_defined: bool,
286    binding: Option<&Value>,
287    name: &str,
288    arg_vals: &[Value],
289) -> Option<Result<Value>> {
290    if !can_dispatch_builtin_check(is_defined, binding, name) {
291        return None;
292    }
293    match name {
294        "range" => Some(builtin_range(arg_vals)),
295        "cycler" => {
296            let id = cyclers.len();
297            cyclers.push(CyclerState::new(arg_vals.to_vec()));
298            Some(Ok(cycler_handle_value(id)))
299        }
300        "joiner" => {
301            let sep = match arg_vals.len() {
302                0 => ",".to_string(),
303                1 => {
304                    let s = crate::value::value_to_string(&arg_vals[0]);
305                    if s.is_empty() {
306                        ",".to_string()
307                    } else {
308                        s
309                    }
310                }
311                _ => return Some(Err(RunjucksError::new("`joiner` expects 0 or 1 arguments"))),
312            };
313            let id = joiners.len();
314            joiners.push(JoinerState::new(sep));
315            Some(Ok(joiner_handle_value(id)))
316        }
317        _ => None,
318    }
319}
320
321/// If `e` is a chain of `.attr` segments on a plain variable (`foo.bar.baz`), returns the root
322/// name and path segments in order.
323pub fn collect_attr_chain_from_getattr<'a>(mut e: &'a Expr) -> Option<(&'a str, Vec<&'a str>)> {
324    let mut attrs: Vec<&'a str> = Vec::new();
325    loop {
326        match e {
327            Expr::GetAttr { base, attr } => {
328                attrs.push(attr.as_str());
329                e = base.as_ref();
330            }
331            Expr::Variable(name) => {
332                attrs.reverse();
333                return Some((name.as_str(), attrs));
334            }
335            _ => return None,
336        }
337    }
338}
339
340/// Peels a chain of built-in `upper` / `lower` / `capitalize` / `trim` / `length` filters.
341/// Returns filter names in **application** order and the leaf expression.
342pub fn peel_builtin_upper_lower_length_chain<'a>(
343    mut e: &'a Expr,
344    custom_filters: &std::collections::HashMap<String, crate::environment::CustomFilter>,
345) -> Option<(Vec<&'a str>, &'a Expr)> {
346    let mut names: Vec<&'a str> = Vec::new();
347    loop {
348        match e {
349            Expr::Filter { name, input, args }
350                if args.is_empty() && !custom_filters.contains_key(name) =>
351            {
352                let n = name.as_str();
353                if !matches!(n, "upper" | "lower" | "length" | "trim" | "capitalize") {
354                    return None;
355                }
356                names.push(n);
357                e = input.as_ref();
358            }
359            _ => break,
360        }
361    }
362    if names.is_empty() {
363        return None;
364    }
365    names.reverse();
366    if !builtin_filter_chain_application_order_valid(&names) {
367        return None;
368    }
369    Some((names, e))
370}
371
372/// `length` may only appear as the final step.
373pub fn builtin_filter_chain_application_order_valid(rev_names: &[&str]) -> bool {
374    if rev_names.is_empty() {
375        return false;
376    }
377    let last = rev_names.len() - 1;
378    for (i, &name) in rev_names.iter().enumerate() {
379        match name {
380            "upper" | "lower" | "trim" | "capitalize" => {}
381            "length" => {
382                if i != last {
383                    return false;
384                }
385            }
386            _ => return false,
387        }
388    }
389    true
390}
391
392/// Applies a chain of builtin filters on a Cow value.
393pub fn apply_builtin_filter_chain_on_cow_value(
394    mut current: Cow<'_, Value>,
395    rev_names: &[&str],
396) -> Result<Value> {
397    for n in rev_names {
398        match *n {
399            "upper" => {
400                let t = crate::value::value_to_string(current.as_ref()).to_uppercase();
401                current = Cow::Owned(Value::String(t));
402            }
403            "lower" => {
404                let t = crate::value::value_to_string(current.as_ref()).to_lowercase();
405                current = Cow::Owned(Value::String(t));
406            }
407            "trim" => {
408                let t = crate::filters::chain_trim_like_builtin(current.as_ref());
409                current = Cow::Owned(t);
410            }
411            "capitalize" => {
412                let t = crate::filters::chain_capitalize_like_builtin(current.as_ref());
413                current = Cow::Owned(t);
414            }
415            "length" => {
416                return Ok(match current.as_ref() {
417                    Value::String(s) => json!(s.chars().count()),
418                    Value::Array(a) => json!(a.len()),
419                    Value::Object(o) => json!(o.len()),
420                    x if is_undefined_value(x) => json!(0),
421                    _ => json!(0),
422                });
423            }
424            _ => unreachable!(),
425        }
426    }
427    Ok(current.into_owned())
428}