use minijinja::{Environment, Value};
use std::collections::HashMap;
use crate::error::RenderError;
use crate::width::WidthPolicySource;
use crate::AmbiguousWidth;
pub trait TemplateEngine {
fn render_template(
&self,
template: &str,
data: &serde_json::Value,
) -> Result<String, RenderError>;
fn render_template_with_width(
&self,
template: &str,
data: &serde_json::Value,
_policy: AmbiguousWidth,
) -> Result<String, RenderError> {
self.render_template(template, data)
}
fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError>;
fn render_named(&self, name: &str, data: &serde_json::Value) -> Result<String, RenderError>;
fn render_named_with_width(
&self,
name: &str,
data: &serde_json::Value,
_policy: AmbiguousWidth,
) -> Result<String, RenderError> {
self.render_named(name, data)
}
fn has_template(&self, name: &str) -> bool;
fn render_with_context(
&self,
template: &str,
data: &serde_json::Value,
context: HashMap<String, serde_json::Value>,
) -> Result<String, RenderError>;
fn render_with_context_and_width(
&self,
template: &str,
data: &serde_json::Value,
context: HashMap<String, serde_json::Value>,
_policy: AmbiguousWidth,
) -> Result<String, RenderError> {
self.render_with_context(template, data, context)
}
fn supports_includes(&self) -> bool;
fn supports_filters(&self) -> bool;
fn supports_control_flow(&self) -> bool;
}
pub struct MiniJinjaEngine {
env: Environment<'static>,
width_policy: WidthPolicySource,
}
impl MiniJinjaEngine {
pub fn new() -> Self {
let mut env = Environment::new();
let width_policy = WidthPolicySource::new(AmbiguousWidth::Narrow);
register_filters_with_source(&mut env, width_policy.clone());
Self { env, width_policy }
}
pub fn environment(&self) -> &Environment<'static> {
&self.env
}
pub fn environment_mut(&mut self) -> &mut Environment<'static> {
&mut self.env
}
}
impl Default for MiniJinjaEngine {
fn default() -> Self {
Self::new()
}
}
impl TemplateEngine for MiniJinjaEngine {
fn render_template(
&self,
template: &str,
data: &serde_json::Value,
) -> Result<String, RenderError> {
let _policy = self.width_policy.scoped(AmbiguousWidth::Narrow);
self.render_template_inner(template, data)
}
fn render_template_with_width(
&self,
template: &str,
data: &serde_json::Value,
policy: AmbiguousWidth,
) -> Result<String, RenderError> {
let _policy = self.width_policy.scoped(policy);
self.render_template_inner(template, data)
}
fn add_template(&mut self, name: &str, source: &str) -> Result<(), RenderError> {
self.env
.add_template_owned(name.to_string(), source.to_string())?;
Ok(())
}
fn render_named(&self, name: &str, data: &serde_json::Value) -> Result<String, RenderError> {
let _policy = self.width_policy.scoped(AmbiguousWidth::Narrow);
self.render_named_inner(name, data)
}
fn render_named_with_width(
&self,
name: &str,
data: &serde_json::Value,
policy: AmbiguousWidth,
) -> Result<String, RenderError> {
let _policy = self.width_policy.scoped(policy);
self.render_named_inner(name, data)
}
fn has_template(&self, name: &str) -> bool {
self.env.get_template(name).is_ok()
}
fn render_with_context(
&self,
template: &str,
data: &serde_json::Value,
context: HashMap<String, serde_json::Value>,
) -> Result<String, RenderError> {
let _policy = self.width_policy.scoped(AmbiguousWidth::Narrow);
self.render_with_context_inner(template, data, context)
}
fn render_with_context_and_width(
&self,
template: &str,
data: &serde_json::Value,
context: HashMap<String, serde_json::Value>,
policy: AmbiguousWidth,
) -> Result<String, RenderError> {
let _policy = self.width_policy.scoped(policy);
self.render_with_context_inner(template, data, context)
}
fn supports_includes(&self) -> bool {
true
}
fn supports_filters(&self) -> bool {
true
}
fn supports_control_flow(&self) -> bool {
true
}
}
impl MiniJinjaEngine {
fn render_template_inner(
&self,
template: &str,
data: &serde_json::Value,
) -> Result<String, RenderError> {
let value = Value::from_serialize(data);
Ok(self.env.render_str(template, value)?)
}
fn render_named_inner(
&self,
name: &str,
data: &serde_json::Value,
) -> Result<String, RenderError> {
let tmpl = self.env.get_template(name)?;
let value = Value::from_serialize(data);
Ok(tmpl.render(value)?)
}
fn render_with_context_inner(
&self,
template: &str,
data: &serde_json::Value,
context: HashMap<String, serde_json::Value>,
) -> Result<String, RenderError> {
let mut combined = HashMap::new();
for (key, value) in context {
combined.insert(key, Value::from_serialize(value));
}
if let serde_json::Value::Object(map) = data {
for (key, value) in map {
combined.insert(key.clone(), Value::from_serialize(value));
}
}
Ok(self.env.render_str(template, &combined)?)
}
}
pub fn register_filters(env: &mut Environment<'static>) {
register_filters_with_policy(env, AmbiguousWidth::Narrow);
}
pub fn register_filters_with_policy(env: &mut Environment<'static>, policy: AmbiguousWidth) {
register_filters_with_source(env, WidthPolicySource::new(policy));
}
fn register_filters_with_source(env: &mut Environment<'static>, policy: WidthPolicySource) {
use minijinja::{Error, ErrorKind};
env.add_filter("nl", |value: Value| -> String { format!("{}\n", value) });
env.add_filter(
"style",
|_value: Value, _name: String| -> Result<String, Error> {
Err(Error::new(
ErrorKind::InvalidOperation,
"The `style()` filter was removed in Standout 1.0. \
Use tag syntax instead: [stylename]{{ value }}[/stylename]",
))
},
);
crate::tabular::filters::register_tabular_filters_with_source(env, policy);
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc, Barrier,
};
use std::time::Duration;
#[derive(Serialize)]
struct TestData {
name: String,
count: usize,
}
#[test]
fn test_minijinja_engine_simple() {
let engine = MiniJinjaEngine::new();
let data = TestData {
name: "World".into(),
count: 42,
};
let data_value = serde_json::to_value(&data).unwrap();
let output = engine
.render_template("Hello, {{ name }}!", &data_value)
.unwrap();
assert_eq!(output, "Hello, World!");
}
#[test]
fn test_minijinja_engine_with_loop() {
let engine = MiniJinjaEngine::new();
#[derive(Serialize)]
struct ListData {
items: Vec<String>,
}
let data = ListData {
items: vec!["a".into(), "b".into(), "c".into()],
};
let data_value = serde_json::to_value(&data).unwrap();
let output = engine
.render_template(
"{% for item in items %}{{ item }},{% endfor %}",
&data_value,
)
.unwrap();
assert_eq!(output, "a,b,c,");
}
#[test]
fn test_minijinja_engine_named_template() {
let mut engine = MiniJinjaEngine::new();
engine
.add_template("greeting", "Hello, {{ name }}!")
.unwrap();
let data = TestData {
name: "World".into(),
count: 0,
};
let data_value = serde_json::to_value(&data).unwrap();
let output = engine.render_named("greeting", &data_value).unwrap();
assert_eq!(output, "Hello, World!");
}
#[test]
fn test_minijinja_engine_template_error() {
let engine = MiniJinjaEngine::new();
let result = engine.render_template("{{ unclosed", &serde_json::Value::Null);
assert!(result.is_err());
}
#[test]
fn test_minijinja_engine_with_context() {
let engine = MiniJinjaEngine::new();
#[derive(Serialize)]
struct Data {
name: String,
}
let mut context = HashMap::new();
context.insert(
"version".to_string(),
serde_json::Value::String("1.0.0".into()),
);
let data = Data {
name: "Test".into(),
};
let data_value = serde_json::to_value(&data).unwrap();
let output = engine
.render_with_context("{{ name }} v{{ version }}", &data_value, context)
.unwrap();
assert_eq!(output, "Test v1.0.0");
}
#[test]
fn test_minijinja_engine_supports_features() {
let engine = MiniJinjaEngine::new();
assert!(engine.supports_includes());
assert!(engine.supports_filters());
assert!(engine.supports_control_flow());
}
#[test]
fn width_policy_is_restored_after_a_filter_panics() {
let mut engine = MiniJinjaEngine::new();
engine
.environment_mut()
.add_filter("panic_now", |_value: Value| -> String {
panic!("intentional filter panic")
});
let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = engine.render_template_with_width(
"{{ '≈' | panic_now }}",
&serde_json::Value::Null,
AmbiguousWidth::Wide,
);
}));
assert!(panic.is_err());
assert_eq!(
engine
.render_template("{{ '≈' | display_width }}", &serde_json::Value::Null)
.unwrap(),
"1"
);
}
#[test]
fn concurrent_renders_cannot_cross_contaminate_width_policies() {
let mut engine = MiniJinjaEngine::new();
let active = Arc::new(AtomicUsize::new(0));
let active_filter = Arc::clone(&active);
engine
.environment_mut()
.add_filter("pause", move |value: Value| -> String {
assert_eq!(active_filter.fetch_add(1, Ordering::SeqCst), 0);
std::thread::sleep(Duration::from_millis(2));
active_filter.fetch_sub(1, Ordering::SeqCst);
value.to_string()
});
let engine = Arc::new(engine);
let start = Arc::new(Barrier::new(3));
let spawn_render = |policy, expected: &'static str| {
let engine = Arc::clone(&engine);
let start = Arc::clone(&start);
std::thread::spawn(move || {
start.wait();
for _ in 0..12 {
let rendered = engine
.render_template_with_width(
"{{ '≈' | pause | display_width }}",
&serde_json::Value::Null,
policy,
)
.unwrap();
assert_eq!(rendered, expected);
}
})
};
let narrow = spawn_render(AmbiguousWidth::Narrow, "1");
let wide = spawn_render(AmbiguousWidth::Wide, "2");
start.wait();
narrow.join().unwrap();
wide.join().unwrap();
assert_eq!(active.load(Ordering::SeqCst), 0);
}
#[test]
fn named_and_context_renders_cross_the_same_policy_seam() {
let mut engine = MiniJinjaEngine::new();
engine
.add_template("width", "{{ value | display_width }}")
.unwrap();
let data = serde_json::json!({ "value": "≈" });
assert_eq!(
engine
.render_named_with_width("width", &data, AmbiguousWidth::Wide)
.unwrap(),
"2"
);
assert_eq!(
engine
.render_with_context_and_width(
"{{ value | display_width }}",
&serde_json::Value::Null,
HashMap::from([("value".to_string(), serde_json::json!("≈"))]),
AmbiguousWidth::Wide,
)
.unwrap(),
"2"
);
}
}