runjucks_core/
environment.rs

1//! [`Environment`] holds render options and is the entry point for [`Environment::render_string`].
2//!
3//! It ties together [`crate::lexer::tokenize`], [`crate::parser::parse`], and [`crate::renderer::render`].
4
5use crate::ast::Node;
6use crate::errors::{Result, RunjucksError};
7use crate::extension::{
8    register_extension_inner, remove_extension_inner, CustomExtensionHandler, ExtensionTagMeta,
9};
10use crate::globals::{default_globals_map, value_is_callable, RJ_CALLABLE};
11use crate::lexer::{LexerOptions, Tags};
12use crate::loader::TemplateLoader;
13use crate::parser::is_reserved_tag_keyword;
14use crate::value::{
15    is_marked_safe, is_regexp_value, is_undefined_value, undefined_value, value_to_string,
16};
17use crate::{lexer, parser, renderer};
18use serde_json::{Map, Value};
19use std::borrow::Cow;
20use std::collections::{HashMap, HashSet};
21use std::hash::{Hash, Hasher};
22use std::sync::{Arc, Mutex};
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25struct ParseSignature {
26    trim_blocks: bool,
27    lstrip_blocks: bool,
28    tags: Option<Tags>,
29    extension_tag_keys: Vec<String>,
30    extension_closing_names: Vec<String>,
31}
32
33struct CachedParse {
34    sig: ParseSignature,
35    ast: Arc<Node>,
36    /// Full source at parse time (validates hash collisions for inline cache; detects loader changes for named cache).
37    source: Option<String>,
38}
39
40fn hash_source(s: &str) -> u64 {
41    use std::collections::hash_map::DefaultHasher;
42    let mut h = DefaultHasher::new();
43    s.hash(&mut h);
44    h.finish()
45}
46
47/// User-registered filter (Nunjucks `addFilter`). Invoked as `(input, extra_args…)`.
48///
49/// When a custom filter has the same name as a built-in, the custom filter wins (Nunjucks behavior).
50pub type CustomFilter = Arc<dyn Fn(&Value, &[Value]) -> Result<Value> + Send + Sync>;
51
52/// User-registered `is` test (Nunjucks `addTest`). Invoked as `(value, extra_args…) -> bool`.
53pub type CustomTest = Arc<dyn Fn(&Value, &[Value]) -> Result<bool> + Send + Sync>;
54
55/// User-registered global **function** (Nunjucks `addGlobal` with a JS function in Node).
56///
57/// Positional args are passed in order; keyword args are passed as a single trailing object value
58/// (Nunjucks keyword-argument convention), represented as `[(String, Value)]` before marshalling.
59pub type CustomGlobalFn = Arc<dyn Fn(&[Value], &[(String, Value)]) -> Result<Value> + Send + Sync>;
60
61/// Async variant of [`CustomFilter`]. Returns a boxed future.
62#[cfg(feature = "async")]
63pub type AsyncCustomFilter = Arc<
64    dyn Fn(
65            &Value,
66            &[Value],
67        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send>>
68        + Send
69        + Sync,
70>;
71
72/// Async variant of [`CustomGlobalFn`]. Returns a boxed future.
73#[cfg(feature = "async")]
74pub type AsyncCustomGlobalFn = Arc<
75    dyn Fn(
76            &[Value],
77            &[(String, Value)],
78        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send>>
79        + Send
80        + Sync,
81>;
82
83/// Introspection-only descriptor for a registered extension (Nunjucks `getExtension` analog).
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct ExtensionDescriptor {
86    pub name: String,
87    pub tags: Vec<String>,
88    pub blocks: HashMap<String, String>,
89}
90
91/// Configuration and entry point for rendering templates.
92///
93/// # Fields
94///
95/// - **`autoescape`**: When `true` (the default), HTML-escapes string output from variable tags via
96///   [`crate::filters::escape_html`].
97/// - **`dev`**: Reserved for developer-mode behavior (e.g. richer errors); currently unused in the renderer.
98/// - **`throw_on_undefined`**: When `true`, unbound variables are errors instead of the internal undefined sentinel.
99/// - **`loader`**: Optional [`TemplateLoader`] for [`Environment::render_template`], `{% include %}`, `{% import %}`, `{% from %}`, and `{% extends %}`.
100///
101/// # Default
102///
103/// [`Environment::default`] sets `autoescape = true`, `dev = false`, and `loader = None`.
104///
105/// # Examples
106///
107/// ```
108/// use runjucks_core::Environment;
109/// use serde_json::json;
110///
111/// let mut env = Environment::default();
112/// env.autoescape = false;
113/// let out = env.render_string("<{{ x }}>".into(), json!({ "x": "b" })).unwrap();
114/// assert_eq!(out, "<b>");
115/// ```
116#[derive(Clone)]
117pub struct Environment {
118    /// When true, variable output is passed through [`crate::filters::escape_html`].
119    pub autoescape: bool,
120    /// Developer mode flag (reserved).
121    pub dev: bool,
122    /// Resolves template names for [`Environment::render_template`], `include`, and `extends`.
123    pub loader: Option<Arc<dyn TemplateLoader + Send + Sync>>,
124    /// Nunjucks-style globals: used when a name is not bound in the template context (context wins if the key exists, including `null`).
125    pub globals: HashMap<String, Value>,
126    /// When true, an unbound variable name (not in context or globals) is a render error instead of [`crate::value::undefined_value`].
127    pub throw_on_undefined: bool,
128    /// When set, [`crate::filters::apply_builtin`] `random` uses this seed for reproducible output (conformance / tests).
129    pub random_seed: Option<u64>,
130    /// When true, the first newline after a block tag (`{% … %}`) is automatically removed (Nunjucks `trimBlocks`).
131    pub trim_blocks: bool,
132    /// When true, leading whitespace/tabs on a line before a block tag or comment are stripped (Nunjucks `lstripBlocks`).
133    pub lstrip_blocks: bool,
134    /// Custom delimiter strings (Nunjucks `tags` key in `configure`). `None` uses default delimiters.
135    pub tags: Option<Tags>,
136    pub(crate) custom_filters: HashMap<String, CustomFilter>,
137    pub(crate) custom_tests: HashMap<String, CustomTest>,
138    /// Nunjucks `addGlobal` with a callable (Node: JS function; tests: [`Environment::add_global_callable`]).
139    pub(crate) custom_globals: HashMap<String, CustomGlobalFn>,
140    /// Custom tag names → extension metadata (see [`Environment::register_extension`]).
141    pub(crate) extension_tags: HashMap<String, ExtensionTagMeta>,
142    pub(crate) extension_closing_tag_names: HashSet<String>,
143    pub(crate) custom_extensions: HashMap<String, CustomExtensionHandler>,
144    inline_parse_cache: Arc<Mutex<HashMap<u64, CachedParse>>>,
145    named_parse_cache: Arc<Mutex<HashMap<String, CachedParse>>>,
146    #[cfg(feature = "async")]
147    pub(crate) async_custom_filters: HashMap<String, AsyncCustomFilter>,
148    #[cfg(feature = "async")]
149    pub(crate) async_custom_globals: HashMap<String, AsyncCustomGlobalFn>,
150}
151
152impl std::fmt::Debug for Environment {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("Environment")
155            .field("autoescape", &self.autoescape)
156            .field("dev", &self.dev)
157            .field("loader", &self.loader.is_some())
158            .field("globals_len", &self.globals.len())
159            .field("custom_filters_len", &self.custom_filters.len())
160            .field("custom_tests_len", &self.custom_tests.len())
161            .field("custom_globals_len", &self.custom_globals.len())
162            .field("extension_tags_len", &self.extension_tags.len())
163            .field("throw_on_undefined", &self.throw_on_undefined)
164            .field("random_seed", &self.random_seed)
165            .finish()
166    }
167}
168
169fn is_truthy_value(v: &Value) -> bool {
170    if is_undefined_value(v) {
171        return false;
172    }
173    match v {
174        Value::Null | Value::Bool(false) => false,
175        Value::Bool(true) => true,
176        Value::Number(n) => n.as_f64().map(|x| x != 0.0 && !x.is_nan()).unwrap_or(true),
177        Value::String(s) => !s.is_empty(),
178        Value::Array(_) | Value::Object(_) => true,
179    }
180}
181
182fn as_is_test_integer(v: &Value) -> Result<i64> {
183    v.as_i64()
184        .or_else(|| v.as_f64().map(|x| x as i64))
185        .ok_or_else(|| RunjucksError::new("test expected a number"))
186}
187
188/// `ToNumber`-style coercion for Nunjucks relational `is` tests (`gt`, `ge`, …).
189fn to_number_for_is_compare(v: &Value) -> Option<f64> {
190    if is_undefined_value(v) {
191        return None;
192    }
193    match v {
194        Value::Null => Some(0.0),
195        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
196        Value::Number(n) => n.as_f64(),
197        Value::String(s) => s.trim().parse::<f64>().ok(),
198        _ => None,
199    }
200}
201
202fn relational_ordering(value: &Value, other: &Value) -> Option<std::cmp::Ordering> {
203    let (n1, n2) = (
204        to_number_for_is_compare(value),
205        to_number_for_is_compare(other),
206    );
207    if let (Some(a), Some(b)) = (n1, n2) {
208        if a.is_nan() || b.is_nan() {
209            return None;
210        }
211        return a.partial_cmp(&b);
212    }
213    if let (Value::String(s1), Value::String(s2)) = (value, other) {
214        return Some(s1.cmp(s2));
215    }
216    None
217}
218
219fn is_test_gt(value: &Value, arg_vals: &[Value]) -> bool {
220    let Some(other) = arg_vals.first() else {
221        return false;
222    };
223    relational_ordering(value, other)
224        .map(|o| o == std::cmp::Ordering::Greater)
225        .unwrap_or(false)
226}
227
228fn is_test_ge(value: &Value, arg_vals: &[Value]) -> bool {
229    let Some(other) = arg_vals.first() else {
230        return false;
231    };
232    relational_ordering(value, other)
233        .map(|o| o == std::cmp::Ordering::Greater || o == std::cmp::Ordering::Equal)
234        .unwrap_or(false)
235}
236
237fn is_test_lt(value: &Value, arg_vals: &[Value]) -> bool {
238    let Some(other) = arg_vals.first() else {
239        return false;
240    };
241    relational_ordering(value, other)
242        .map(|o| o == std::cmp::Ordering::Less)
243        .unwrap_or(false)
244}
245
246fn is_test_le(value: &Value, arg_vals: &[Value]) -> bool {
247    let Some(other) = arg_vals.first() else {
248        return false;
249    };
250    relational_ordering(value, other)
251        .map(|o| o == std::cmp::Ordering::Less || o == std::cmp::Ordering::Equal)
252        .unwrap_or(false)
253}
254
255fn is_test_iterable(v: &Value) -> bool {
256    match v {
257        Value::String(_) | Value::Array(_) => true,
258        Value::Object(_) if is_undefined_value(v) || is_marked_safe(v) || is_regexp_value(v) => {
259            false
260        }
261        Value::Object(_) => false,
262        _ => false,
263    }
264}
265
266fn is_test_mapping(v: &Value) -> bool {
267    if is_undefined_value(v) {
268        return false;
269    }
270    match v {
271        Value::Object(_) if is_marked_safe(v) || is_regexp_value(v) => false,
272        Value::Object(_) => true,
273        _ => false,
274    }
275}
276
277/// Nunjucks `equalto` / `sameas` (`===`): same template variable binding is always true; two object
278/// or array **values** from distinct bindings are never equal (reference semantics); primitives use
279/// JSON equality. Used from templates and from `select` / `reject` (always `same_binding: false`).
280pub(crate) fn equalto_sameas_pair(
281    left: &Value,
282    right: &Value,
283    same_template_variable: bool,
284) -> bool {
285    if same_template_variable {
286        return true;
287    }
288    match (left, right) {
289        (Value::Object(_), Value::Object(_)) | (Value::Array(_), Value::Array(_)) => false,
290        _ => left == right,
291    }
292}
293
294impl Default for Environment {
295    /// Returns an environment with `autoescape = true` and `dev = false`.
296    fn default() -> Self {
297        Self {
298            autoescape: true,
299            dev: false,
300            loader: None,
301            globals: default_globals_map(),
302            throw_on_undefined: false,
303            random_seed: None,
304            trim_blocks: false,
305            lstrip_blocks: false,
306            tags: None,
307            custom_filters: HashMap::new(),
308            custom_tests: HashMap::new(),
309            custom_globals: HashMap::new(),
310            extension_tags: HashMap::new(),
311            extension_closing_tag_names: HashSet::new(),
312            custom_extensions: HashMap::new(),
313            inline_parse_cache: Arc::new(Mutex::new(HashMap::new())),
314            named_parse_cache: Arc::new(Mutex::new(HashMap::new())),
315            #[cfg(feature = "async")]
316            async_custom_filters: HashMap::new(),
317            #[cfg(feature = "async")]
318            async_custom_globals: HashMap::new(),
319        }
320    }
321}
322
323impl Environment {
324    fn current_parse_signature(&self) -> ParseSignature {
325        let mut keys: Vec<_> = self.extension_tags.keys().cloned().collect();
326        keys.sort();
327        let mut closing: Vec<_> = self.extension_closing_tag_names.iter().cloned().collect();
328        closing.sort();
329        ParseSignature {
330            trim_blocks: self.trim_blocks,
331            lstrip_blocks: self.lstrip_blocks,
332            tags: self.tags.clone(),
333            extension_tag_keys: keys,
334            extension_closing_names: closing,
335        }
336    }
337
338    fn parse_source_to_ast(&self, src: &str) -> Result<Node> {
339        let tokens = lexer::tokenize_with_options(src, self.lexer_options())?;
340        parser::parse_with_env(
341            &tokens,
342            &self.extension_tags,
343            &self.extension_closing_tag_names,
344        )
345    }
346
347    /// Parses template source using the inline parse cache (hash of source + parse signature).
348    pub fn parse_or_cached_inline(&self, src: &str) -> Result<Arc<Node>> {
349        let sig = self.current_parse_signature();
350        let key = hash_source(src);
351        {
352            let cache = self.inline_parse_cache.lock().unwrap();
353            if let Some(c) = cache.get(&key) {
354                if c.sig == sig && c.source.as_deref() == Some(src) {
355                    return Ok(Arc::clone(&c.ast));
356                }
357            }
358        }
359        let node = self.parse_source_to_ast(src)?;
360        let arc = Arc::new(node);
361        let mut cache = self.inline_parse_cache.lock().unwrap();
362        cache.insert(
363            key,
364            CachedParse {
365                sig,
366                ast: Arc::clone(&arc),
367                source: Some(src.to_string()),
368            },
369        );
370        Ok(arc)
371    }
372
373    /// Loads a template by name and returns a parsed AST, using the named parse cache when the loader supplies a [`TemplateLoader::cache_key`].
374    pub(crate) fn load_and_parse_named(
375        &self,
376        name: &str,
377        loader: &(dyn TemplateLoader + Send + Sync),
378    ) -> Result<Arc<Node>> {
379        let src = loader.load(name)?;
380        self.parse_with_named_cache(name, loader, &src)
381    }
382
383    fn parse_with_named_cache(
384        &self,
385        name: &str,
386        loader: &(dyn TemplateLoader + Send + Sync),
387        src: &str,
388    ) -> Result<Arc<Node>> {
389        let sig = self.current_parse_signature();
390        if let Some(ref key) = loader.cache_key(name) {
391            {
392                let cache = self.named_parse_cache.lock().unwrap();
393                if let Some(c) = cache.get(key) {
394                    if c.sig == sig && c.source.as_deref() == Some(src) {
395                        return Ok(Arc::clone(&c.ast));
396                    }
397                }
398            }
399            let node = self.parse_source_to_ast(src)?;
400            let arc = Arc::new(node);
401            let mut cache = self.named_parse_cache.lock().unwrap();
402            cache.insert(
403                key.clone(),
404                CachedParse {
405                    sig,
406                    ast: Arc::clone(&arc),
407                    source: Some(src.to_string()),
408                },
409            );
410            Ok(arc)
411        } else {
412            let node = self.parse_source_to_ast(src)?;
413            Ok(Arc::new(node))
414        }
415    }
416
417    /// Clears the named-template parse cache (e.g. after replacing the template loader).
418    pub fn clear_named_parse_cache(&self) {
419        self.named_parse_cache.lock().unwrap().clear();
420    }
421
422    /// Clears **all** parse caches: named templates ([`load_and_parse_named`]) and inline
423    /// [`parse_or_cached_inline`] entries (Nunjucks `Environment#invalidateCache` analog).
424    pub fn invalidate_cache(&self) {
425        self.named_parse_cache.lock().unwrap().clear();
426        self.inline_parse_cache.lock().unwrap().clear();
427    }
428
429    /// Renders a parsed AST without lexing/parsing (caller must use the same environment configuration as when the AST was produced).
430    pub fn render_parsed(&self, ast: &Node, context: Value) -> Result<String> {
431        let root = match context {
432            Value::Object(m) => m,
433            _ => Map::new(),
434        };
435        let mut stack = renderer::CtxStack::from_root(root);
436        let loader = self.loader.as_ref().map(|arc| arc.as_ref());
437        renderer::render(self, loader, ast, &mut stack)
438    }
439
440    /// Registers or replaces a global value (Nunjucks `addGlobal`). Names still lose to template context keys with the same name.
441    ///
442    /// Replacing a global with a JSON value clears any registered [`Environment::add_global_callable`] for that name.
443    pub fn add_global(&mut self, name: impl Into<String>, value: Value) -> &mut Self {
444        let name = name.into();
445        self.custom_globals.remove(&name);
446        self.globals.insert(name, value);
447        self
448    }
449
450    /// Registers a global **function** implemented in Rust (tests / embedders). Node callers use NAPI `addGlobal` with a JS function.
451    ///
452    /// The template sees a [`crate::globals::RJ_CALLABLE`] marker for `is callable` / variable resolution; invocation uses `f`.
453    pub fn add_global_callable(&mut self, name: impl Into<String>, f: CustomGlobalFn) -> &mut Self {
454        let name = name.into();
455        let mut m = Map::new();
456        m.insert(RJ_CALLABLE.to_string(), Value::Bool(true));
457        self.globals.insert(name.clone(), Value::Object(m));
458        self.custom_globals.insert(name, f);
459        self
460    }
461
462    /// Registers or replaces a custom filter (Nunjucks `addFilter`). Overrides a built-in with the same name.
463    pub fn add_filter(&mut self, name: impl Into<String>, filter: CustomFilter) -> &mut Self {
464        self.custom_filters.insert(name.into(), filter);
465        self
466    }
467
468    /// Registers or replaces a custom `is` test (Nunjucks `addTest`). Used by `x is name` and by `select` / `reject`.
469    pub fn add_test(&mut self, name: impl Into<String>, test: CustomTest) -> &mut Self {
470        self.custom_tests.insert(name.into(), test);
471        self
472    }
473
474    /// Registers a custom tag extension (Nunjucks `addExtension`): `tag_specs` lists `(opening_tag, optional_end_tag)`.
475    pub fn register_extension(
476        &mut self,
477        extension_name: impl Into<String>,
478        tag_specs: Vec<(String, Option<String>)>,
479        handler: CustomExtensionHandler,
480    ) -> Result<()> {
481        let extension_name = extension_name.into();
482        register_extension_inner(
483            &mut self.extension_tags,
484            &mut self.extension_closing_tag_names,
485            &mut self.custom_extensions,
486            extension_name,
487            tag_specs,
488            handler,
489            is_reserved_tag_keyword,
490        )
491    }
492
493    /// Returns whether a custom extension with this name is registered (Nunjucks `hasExtension`).
494    pub fn has_extension(&self, name: &str) -> bool {
495        self.custom_extensions.contains_key(name)
496    }
497
498    /// Returns metadata for a registered extension name without exposing the internal handler.
499    pub fn get_extension_descriptor(&self, name: &str) -> Option<ExtensionDescriptor> {
500        if !self.custom_extensions.contains_key(name) {
501            return None;
502        }
503        let mut tags = Vec::new();
504        let mut blocks = HashMap::new();
505        for (tag, meta) in &self.extension_tags {
506            if meta.extension_name == name {
507                tags.push(tag.clone());
508                if let Some(end) = &meta.end_tag {
509                    blocks.insert(tag.clone(), end.clone());
510                }
511            }
512        }
513        tags.sort();
514        Some(ExtensionDescriptor {
515            name: name.to_string(),
516            tags,
517            blocks,
518        })
519    }
520
521    /// Unregisters a custom extension by name (Nunjucks `removeExtension`). Returns `true` if it existed.
522    pub fn remove_extension(&mut self, name: &str) -> bool {
523        remove_extension_inner(
524            &mut self.extension_tags,
525            &mut self.extension_closing_tag_names,
526            &mut self.custom_extensions,
527            name,
528        )
529    }
530
531    /// Lexes and parses `src` with this environment’s extension tags (for eager-compile validation).
532    pub fn validate_lex_parse(&self, src: &str) -> Result<()> {
533        let tokens = lexer::tokenize_with_options(src, self.lexer_options())?;
534        let _ = parser::parse_with_env(
535            &tokens,
536            &self.extension_tags,
537            &self.extension_closing_tag_names,
538        )?;
539        Ok(())
540    }
541
542    pub(crate) fn eval_user_is_test(
543        &self,
544        name: &str,
545        value: &Value,
546        args: &[Value],
547    ) -> Result<bool> {
548        match self.custom_tests.get(name) {
549            Some(t) => t(value, args),
550            None => Err(RunjucksError::new(format!("unknown test: `{name}`"))),
551        }
552    }
553
554    /// Built-in and user-registered `is` tests (`x is name`, `select` / `reject`). Argument values are already evaluated.
555    pub(crate) fn apply_is_test(
556        &self,
557        test_name: &str,
558        value: &Value,
559        arg_vals: &[Value],
560    ) -> Result<bool> {
561        match test_name {
562            "equalto" | "eq" | "sameas" => Ok(match arg_vals.first() {
563                Some(a) => equalto_sameas_pair(value, a, false),
564                None => false,
565            }),
566            "null" | "none" => Ok(value.is_null()),
567            "undefined" => Ok(is_undefined_value(value)),
568            "escaped" => Ok(is_marked_safe(value)),
569            "falsy" => Ok(!is_truthy_value(value)),
570            "truthy" => Ok(is_truthy_value(value)),
571            "number" => Ok(value.is_number()),
572            "string" => Ok(value.is_string()),
573            "lower" => Ok(match value {
574                Value::String(s) => s.chars().all(|c| !c.is_uppercase()),
575                _ => false,
576            }),
577            "upper" => Ok(match value {
578                Value::String(s) => s.chars().all(|c| !c.is_lowercase()),
579                _ => false,
580            }),
581            "callable" => Ok(value_is_callable(value)),
582            "defined" => Ok(!is_undefined_value(value)),
583            "odd" => {
584                let n = as_is_test_integer(value)?;
585                Ok(n.rem_euclid(2) != 0)
586            }
587            "even" => {
588                let n = as_is_test_integer(value)?;
589                Ok(n.rem_euclid(2) == 0)
590            }
591            "divisibleby" => {
592                let denom = arg_vals
593                    .first()
594                    .and_then(|a| {
595                        a.as_i64()
596                            .or_else(|| a.as_f64().map(|x| x as i64))
597                            .or_else(|| value_to_string(a).parse().ok())
598                    })
599                    .ok_or_else(|| RunjucksError::new("`divisibleby` test expects a divisor"))?;
600                if denom == 0 {
601                    return Ok(false);
602                }
603                let n = as_is_test_integer(value)?;
604                Ok(n.rem_euclid(denom) == 0)
605            }
606            "greaterthan" | "gt" => Ok(is_test_gt(value, arg_vals)),
607            "lessthan" | "lt" => Ok(is_test_lt(value, arg_vals)),
608            "ge" => Ok(is_test_ge(value, arg_vals)),
609            "le" => Ok(is_test_le(value, arg_vals)),
610            "ne" => Ok(match arg_vals.first() {
611                Some(a) => value != a,
612                None => !is_undefined_value(value),
613            }),
614            "iterable" => Ok(is_test_iterable(value)),
615            "mapping" => Ok(is_test_mapping(value)),
616            _ => self.eval_user_is_test(test_name, value, arg_vals),
617        }
618    }
619
620    /// Resolves a name: template context first (any frame), then [`Environment::globals`].
621    ///
622    /// Unbound names yield [`crate::value::undefined_value`] unless [`Environment::throw_on_undefined`] is set.
623    ///
624    /// Borrows context/globals when possible to avoid cloning on hot paths (see [`Self::resolve_variable`]).
625    pub fn resolve_variable_ref<'a>(
626        &'a self,
627        stack: &'a renderer::CtxStack,
628        name: &str,
629    ) -> Result<Cow<'a, Value>> {
630        if stack.defined(name) {
631            Ok(Cow::Borrowed(stack.get_ref(name).expect(
632                "internal error: variable marked defined but missing from stack",
633            )))
634        } else if let Some(v) = self.globals.get(name) {
635            Ok(Cow::Borrowed(v))
636        } else if self.throw_on_undefined {
637            Err(RunjucksError::new(format!("undefined variable: `{name}`")))
638        } else {
639            Ok(Cow::Owned(undefined_value()))
640        }
641    }
642
643    /// Unbound names yield [`crate::value::undefined_value`] unless [`Environment::throw_on_undefined`] is set.
644    pub fn resolve_variable(&self, stack: &renderer::CtxStack, name: &str) -> Result<Value> {
645        self.resolve_variable_ref(stack, name)
646            .map(|c| c.into_owned())
647    }
648
649    /// Returns the [`LexerOptions`] derived from this environment's configuration.
650    pub fn lexer_options(&self) -> LexerOptions {
651        LexerOptions {
652            trim_blocks: self.trim_blocks,
653            lstrip_blocks: self.lstrip_blocks,
654            tags: self.tags.clone(),
655        }
656    }
657
658    /// Lexes `template`, parses it to an AST, and renders it with `context`.
659    ///
660    /// # Errors
661    ///
662    /// Returns [`crate::errors::RunjucksError`] when:
663    ///
664    /// - The [`crate::lexer`] finds malformed delimiters (e.g. unclosed `{{`).
665    /// - The [`crate::parser`] hits unsupported tag syntax.
666    /// - Rendering fails (currently rare; lookup errors use Nunjucks-style defaults).
667    ///
668    /// # Examples
669    ///
670    /// ```
671    /// use runjucks_core::Environment;
672    /// use serde_json::json;
673    ///
674    /// let env = Environment::default();
675    /// let html = env
676    ///     .render_string("{{ msg }}".into(), json!({ "msg": "<ok>" }))
677    ///     .unwrap();
678    /// assert_eq!(html, "&lt;ok&gt;");
679    /// ```
680    pub fn render_string(&self, template: String, context: Value) -> Result<String> {
681        let ast = self.parse_or_cached_inline(&template)?;
682        self.render_parsed(ast.as_ref(), context)
683    }
684
685    /// Renders a named template using the configured [`TemplateLoader`].
686    ///
687    /// Supports `{% extends %}`, `{% include %}`, `{% import %}`, `{% from %}`, and `{% macro %}` across files.
688    pub fn render_template(&self, name: &str, context: Value) -> Result<String> {
689        let loader = self
690            .loader
691            .as_ref()
692            .ok_or_else(|| RunjucksError::new("no template loader configured"))?;
693        let ast = self.load_and_parse_named(name, loader.as_ref())?;
694        let root = match context {
695            Value::Object(m) => m,
696            _ => Map::new(),
697        };
698        let mut stack = renderer::CtxStack::from_root(root);
699        let mut state = renderer::RenderState::new(Some(loader.as_ref()), self.random_seed);
700        state.push_template(name)?;
701        renderer::scan_literal_extends_graph(self, &mut state, ast.as_ref(), loader.as_ref())?;
702        let out = renderer::render_entry(self, &mut state, ast.as_ref(), &mut stack)?;
703        state.pop_template();
704        Ok(out)
705    }
706
707    /// Registers an async filter. Called as `(input, args…) → Promise<Value>`.
708    #[cfg(feature = "async")]
709    pub fn add_async_filter(&mut self, name: String, filter: AsyncCustomFilter) -> &mut Self {
710        self.async_custom_filters.insert(name, filter);
711        self
712    }
713
714    /// Registers an async global function. Called as `(positional_args…, kwargs) → Promise<Value>`.
715    #[cfg(feature = "async")]
716    pub fn add_async_global_callable(
717        &mut self,
718        name: String,
719        f: AsyncCustomGlobalFn,
720    ) -> &mut Self {
721        let mut m = serde_json::Map::new();
722        m.insert(RJ_CALLABLE.to_string(), Value::Bool(true));
723        self.globals.insert(name.clone(), Value::Object(m));
724        self.async_custom_globals.insert(name, f);
725        self
726    }
727
728    /// Async render of an inline template string. Returns a `Future` that produces the rendered output.
729    #[cfg(feature = "async")]
730    pub async fn render_string_async(&self, template: String, context: Value) -> Result<String> {
731        let ast = self.parse_or_cached_inline(&template)?;
732        crate::async_renderer::render_async(self, ast.as_ref(), context).await
733    }
734
735    /// Async render of a named template. Returns a `Future` that produces the rendered output.
736    #[cfg(feature = "async")]
737    pub async fn render_template_async(&self, name: &str, context: Value) -> Result<String> {
738        let loader = self
739            .loader
740            .as_ref()
741            .ok_or_else(|| RunjucksError::new("no template loader configured"))?;
742        let ast = self.load_and_parse_named(name, loader.as_ref())?;
743        let root = match context {
744            Value::Object(m) => m,
745            _ => serde_json::Map::new(),
746        };
747        let mut stack = renderer::CtxStack::from_root(root);
748        let loader_ref = self.loader.as_ref().map(|l| l.as_ref());
749        let mut state = renderer::RenderState::new(loader_ref, self.random_seed);
750        state.push_template(name)?;
751        renderer::scan_literal_extends_graph(self, &mut state, ast.as_ref(), loader.as_ref())?;
752        let out = crate::async_renderer::entry::render_entry_async(self, &mut state, ast.as_ref(), &mut stack).await?;
753        state.pop_template();
754        Ok(out)
755    }
756}