runjucks_core/
extension.rs

1//! Custom tag extensions (Nunjucks-style [`Environment::register_extension`] / JS `addExtension`).
2
3use crate::errors::{Result, RunjucksError};
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8/// Metadata for a registered extension opening tag (`{% tagname … %}`).
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ExtensionTagMeta {
11    /// Key passed to [`Environment::register_extension`](crate::environment::Environment::register_extension).
12    pub extension_name: String,
13    /// Closing tag for block extensions (e.g. `Some("endwrap")` for `{% wrap %}…{% endwrap %}`).
14    pub end_tag: Option<String>,
15}
16
17/// Sync callback: merged JSON context, raw args string after the tag name, optional rendered body.
18pub type CustomExtensionHandler =
19    Arc<dyn Fn(&Value, &str, Option<String>) -> Result<String> + Send + Sync>;
20
21/// Rebuilds the set of closing-only tag names (for orphan detection).
22pub(crate) fn rebuild_extension_closing_tags(
23    extension_tags: &HashMap<String, ExtensionTagMeta>,
24    out: &mut HashSet<String>,
25) {
26    out.clear();
27    for m in extension_tags.values() {
28        if let Some(e) = &m.end_tag {
29            out.insert(e.clone());
30        }
31    }
32}
33
34/// Validates and applies a full extension registration (replaces any prior tags for this extension name).
35pub(crate) fn register_extension_inner(
36    extension_tags: &mut HashMap<String, ExtensionTagMeta>,
37    extension_closing: &mut HashSet<String>,
38    custom_extensions: &mut HashMap<String, CustomExtensionHandler>,
39    extension_name: String,
40    tag_specs: Vec<(String, Option<String>)>,
41    handler: CustomExtensionHandler,
42    is_reserved: impl Fn(&str) -> bool,
43) -> Result<()> {
44    if tag_specs.is_empty() {
45        return Err(RunjucksError::new(
46            "extension must declare at least one tag in `tags`",
47        ));
48    }
49    let mut seen_tags = HashSet::new();
50    for (tag, _) in &tag_specs {
51        if !seen_tags.insert(tag.clone()) {
52            return Err(RunjucksError::new(format!(
53                "duplicate extension tag `{tag}` in registration"
54            )));
55        }
56    }
57    for (tag, end) in &tag_specs {
58        if is_reserved(tag) {
59            return Err(RunjucksError::new(format!(
60                "extension tag `{tag}` conflicts with a built-in tag"
61            )));
62        }
63        if let Some(e) = end {
64            if is_reserved(e) {
65                return Err(RunjucksError::new(format!(
66                    "extension end tag `{e}` conflicts with a built-in tag"
67                )));
68            }
69        }
70    }
71    for (tag, _) in &tag_specs {
72        if let Some(existing) = extension_tags.get(tag) {
73            if existing.extension_name != extension_name {
74                return Err(RunjucksError::new(format!(
75                    "extension tag `{tag}` is already registered by extension `{}`",
76                    existing.extension_name
77                )));
78            }
79        }
80    }
81    extension_tags.retain(|_, v| v.extension_name != extension_name);
82    custom_extensions.remove(&extension_name);
83    for (tag, end) in tag_specs {
84        extension_tags.insert(
85            tag,
86            ExtensionTagMeta {
87                extension_name: extension_name.clone(),
88                end_tag: end,
89            },
90        );
91    }
92    custom_extensions.insert(extension_name, handler);
93    rebuild_extension_closing_tags(extension_tags, extension_closing);
94    Ok(())
95}
96
97/// Removes a registered extension by name. Returns `true` if an extension handler was removed.
98pub fn remove_extension_inner(
99    extension_tags: &mut HashMap<String, ExtensionTagMeta>,
100    extension_closing: &mut HashSet<String>,
101    custom_extensions: &mut HashMap<String, CustomExtensionHandler>,
102    extension_name: &str,
103) -> bool {
104    let removed = custom_extensions.remove(extension_name).is_some();
105    extension_tags.retain(|_, v| v.extension_name != extension_name);
106    rebuild_extension_closing_tags(extension_tags, extension_closing);
107    removed
108}