Skip to main content

tsx_forge/
engine.rs

1//! The forge Engine — a Tera wrapper with the 4-tier system, import hoisting,
2//! and framework package loading built in.
3
4use std::path::Path;
5use tera::Tera;
6use walkdir::WalkDir;
7
8use crate::{collector, context::ForgeContext, error::ForgeError, filters, provide, slots, tier::Tier};
9
10/// The forge rendering engine.
11///
12/// Wrap Tera with:
13/// - Custom filters: `snake_case`, `pascal_case`, `camel_case`, `kebab_case`
14/// - Import hoisting filters: `collect_import`, `collect_import_priority`
15/// - Import drain function: `render_imports()`
16/// - Tier-aware template registry
17pub struct Engine {
18    tera: Tera,
19}
20
21impl Engine {
22    /// Create an engine with all forge extensions registered. No templates loaded yet.
23    pub fn new() -> Self {
24        let mut tera = Tera::default();
25        register_extensions(&mut tera);
26        Engine { tera }
27    }
28
29    /// Load all `.jinja` and `.forge` template files from `dir` recursively.
30    /// Template names are relative paths from `dir` with forward slashes.
31    pub fn load_dir(&mut self, dir: &Path) -> Result<(), ForgeError> {
32        if !dir.exists() {
33            return Ok(());
34        }
35        for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
36            let path = entry.path();
37            if path.is_file() {
38                let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
39                if ext == "jinja" || ext == "forge" {
40                    let name = path
41                        .strip_prefix(dir)
42                        .map_err(|e| ForgeError::LoadError(e.to_string()))?
43                        .to_string_lossy()
44                        .replace('\\', "/");
45                    let content = std::fs::read_to_string(path)
46                        .map_err(|e| ForgeError::LoadError(e.to_string()))?;
47                    self.tera
48                        .add_raw_template(&name, &content)
49                        .map_err(|e| ForgeError::LoadError(e.to_string()))?;
50                }
51            }
52        }
53        Ok(())
54    }
55
56    /// Load templates from embedded `(name, content)` pairs (for binary embedding).
57    pub fn load_embedded(&mut self, templates: &[(&str, &str)]) -> Result<(), ForgeError> {
58        for (name, content) in templates {
59            self.tera
60                .add_raw_template(name, content)
61                .map_err(|e| ForgeError::LoadError(e.to_string()))?;
62        }
63        Ok(())
64    }
65
66    /// Add a single raw template by name and content string.
67    pub fn add_raw(&mut self, name: &str, content: &str) -> Result<(), ForgeError> {
68        self.tera
69            .add_raw_template(name, content)
70            .map_err(|e| ForgeError::LoadError(e.to_string()))?;
71        Ok(())
72    }
73
74    /// Render a template by name with the given context.
75    /// Resets the ImportCollector and populates slots before rendering.
76    pub fn render(&self, name: &str, ctx: &ForgeContext) -> Result<String, ForgeError> {
77        collector::reset();
78        slots::reset();
79        provide::reset();
80        // Populate thread-local slots from the context
81        if let Some(slot_map) = ctx.slots() {
82            for (k, v) in &slot_map {
83                if let Some(content) = v.as_str() {
84                    slots::fill(k, content);
85                }
86            }
87        }
88        // Populate thread-local provides from the context
89        if let Some(provides_map) = ctx.provides() {
90            for (k, v) in &provides_map {
91                if let Some(content) = v.as_str() {
92                    provide::provide(k, content);
93                }
94            }
95        }
96        self.tera
97            .render(name, ctx.as_tera())
98            .map_err(|e| ForgeError::RenderError(format!("{name}: {e}")))
99    }
100
101    /// Render without resetting the ImportCollector or slots.
102    /// Use when rendering multiple templates in sequence and collecting all their imports.
103    pub fn render_continue(&self, name: &str, ctx: &ForgeContext) -> Result<String, ForgeError> {
104        self.tera
105            .render(name, ctx.as_tera())
106            .map_err(|e| ForgeError::RenderError(format!("{name}: {e}")))
107    }
108
109    /// Return the tier of a template based on its path.
110    pub fn tier_of(&self, name: &str) -> Tier {
111        Tier::from_path(name)
112    }
113
114    /// Check whether a template with this name is loaded.
115    pub fn has_template(&self, name: &str) -> bool {
116        self.tera.get_template_names().any(|n| n == name)
117    }
118}
119
120impl Default for Engine {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126fn register_extensions(tera: &mut Tera) {
127    tera.register_filter("snake_case", filters::snake_case);
128    tera.register_filter("pascal_case", filters::pascal_case);
129    tera.register_filter("camel_case", filters::camel_case);
130    tera.register_filter("kebab_case", filters::kebab_case);
131    tera.register_filter("collect_import", filters::collect_import);
132    tera.register_filter("collect_import_priority", filters::collect_import_priority);
133    tera.register_function("render_imports", filters::render_imports_fn);
134    tera.register_function("slot", slots::make_slot_fn());
135    tera.register_function("inject", provide::make_inject_fn());
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn engine_renders_simple_template() {
144        let mut engine = Engine::new();
145        engine.add_raw("test.jinja", "Hello {{ name | pascal_case }}!").unwrap();
146        let ctx = ForgeContext::new().insert("name", "world");
147        let out = engine.render("test.jinja", &ctx).unwrap();
148        assert_eq!(out, "Hello World!");
149    }
150
151    #[test]
152    fn engine_collect_and_drain_imports() {
153        let mut engine = Engine::new();
154        engine
155            .add_raw(
156                "test.jinja",
157                "{{ 'import React from \"react\"' | collect_import_priority }}{{ 'import { z } from \"zod\"' | collect_import }}{{ render_imports() }}",
158            )
159            .unwrap();
160        let ctx = ForgeContext::new();
161        let out = engine.render("test.jinja", &ctx).unwrap();
162        let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
163        assert_eq!(lines[0], "import React from \"react\"");
164        assert!(lines.iter().any(|l| l.contains("zod")));
165    }
166
167    #[test]
168    fn tier_infers_from_name() {
169        let engine = Engine::new();
170        assert_eq!(engine.tier_of("atoms/drizzle/column.jinja"), Tier::Atom);
171        assert_eq!(engine.tier_of("features/schema.jinja"), Tier::Feature);
172    }
173}