use crate::core::environment::{expr_desc, model_to_string, RunSignal};
use crate::core::eval;
use crate::core::{Element, ElementKind, OutputFormatKind};
use crate::error::{FlowKind, Result, TemplateError};
use crate::template::TModel;
pub enum ExecOutcome {
Next(Vec<Element>),
Replace(Element),
Flow(FlowKind),
ReturnValue(Option<TModel>),
Stop(Option<String>),
Done,
}
pub fn exec(env: &mut crate::core::Environment, el: &Element) -> Result<ExecOutcome> {
match &el.kind {
ElementKind::Text {
text,
strip_before,
strip_after,
orig_end_line,
} => crate::core::text_block::TextBlock::new(
text.clone(),
*strip_before,
*strip_after,
*orig_end_line,
false,
)
.exec(env),
ElementKind::NoParse {
text,
strip_before,
strip_after,
orig_end_line,
} => crate::core::text_block::TextBlock::new(
text.clone(),
*strip_before,
*strip_after,
*orig_end_line,
true,
)
.exec(env),
ElementKind::Interpolation {
expr,
legacy_min_frac,
legacy_max_frac,
} => match (legacy_min_frac, legacy_max_frac) {
(Some(min), Some(max)) => {
crate::core::dollar_variable::NumericalOutput::new(expr.clone(), *min, *max)
.exec(env)
}
_ => crate::core::dollar_variable::DollarVariable::new(expr.clone()).exec(env),
},
ElementKind::If { cond, then, else_ } => {
crate::core::if_block::IfBlock::new(cond.clone(), then.clone(), else_.clone(), el.span)
.exec(env)
}
ElementKind::List {
seq,
var,
var2,
body,
else_,
} => crate::core::iterator_block::IteratorBlock::new(
seq.clone(),
var.clone(),
var2.clone(),
body.clone(),
else_.clone(),
)
.exec(env),
ElementKind::Items { var, var2, body } => {
crate::core::items::Items::new(var.clone(), var2.clone(), body.clone()).exec(env)
}
ElementKind::Sep { body } => crate::core::sep::Sep::new(body.clone()).exec(env),
ElementKind::Assignments(els) => {
crate::core::assignment_instruction::AssignmentInstruction::new(els.clone()).exec(env)
}
ElementKind::Assign {
target,
expr,
op,
namespace,
} => crate::core::assignment::Assignment::new(
target.clone(),
expr.clone(),
*op,
namespace.clone(),
)
.exec(env),
ElementKind::BlockAssign {
target,
body,
namespace,
..
} => crate::core::block_assignment::BlockAssignment::new(
target.clone(),
body.clone(),
namespace.clone(),
)
.exec(env),
ElementKind::Global {
target,
expr,
body,
op,
} => crate::core::global_assignment::GlobalAssignment::new(
target.clone(),
expr.clone(),
body.clone(),
*op,
)
.exec(env),
ElementKind::Local {
target,
expr,
body,
op,
} => crate::core::local_assignment::LocalAssignment::new(
target.clone(),
expr.clone(),
body.clone(),
*op,
)
.exec(env),
ElementKind::Macro { def } => crate::core::r#macro::Macro::new(def.clone()).exec(env),
ElementKind::Call {
callee,
args,
body,
body_params,
} => crate::core::unified_call::UnifiedCall::new(
callee.clone(),
args.clone(),
body.clone(),
body_params.clone(),
el.span,
)
.exec(env),
ElementKind::Nested { args, body: _ } => {
crate::core::body_instruction::BodyInstruction::new(args.clone()).exec(env)
}
ElementKind::Switch {
expr,
cases,
default,
default_pos,
} => crate::core::switch_block::SwitchBlock::new(
expr.clone(),
cases.clone(),
default.clone(),
*default_pos,
)
.exec(env),
ElementKind::Attempt { try_, recover } => {
crate::core::attempt_block::AttemptBlock::new(try_.clone(), recover.clone()).exec(env)
}
ElementKind::Break => crate::core::break_instruction::BreakInstruction::new().exec(env),
ElementKind::Continue => {
crate::core::continue_instruction::ContinueInstruction::new().exec(env)
}
ElementKind::Return { expr } => {
crate::core::return_instruction::ReturnInstruction::new(expr.clone()).exec(env)
}
ElementKind::Stop { msg } => {
crate::core::stop_instruction::StopInstruction::new(msg.clone()).exec(env)
}
ElementKind::Flush => crate::core::flush_instruction::FlushInstruction::new().exec(env),
ElementKind::Trim(body) => {
crate::core::trim_instruction::TrimInstruction::new(body.clone()).exec(env)
}
ElementKind::Comment { text } => crate::core::comment::Comment::new(text.clone()).exec(env),
ElementKind::Include { path, attrs } => {
crate::core::include::Include::new(path.clone(), attrs.clone()).exec(env)
}
ElementKind::Import { path, ns } => {
crate::core::library_load::LibraryLoad::new(path.clone(), ns.clone()).exec(env)
}
ElementKind::Escape { expr, body } => {
crate::core::escape_block::EscapeBlock::new(expr.clone(), body.clone()).exec(env)
}
ElementKind::NoEscape(body) => {
crate::core::no_escape_block::NoEscapeBlock::new(body.clone()).exec(env)
}
ElementKind::AutoEsc(body) => {
crate::core::auto_esc_block::AutoEscBlock::new(body.clone()).exec(env)
}
ElementKind::NoAutoEsc(body) => {
crate::core::no_auto_esc_block::NoAutoEscBlock::new(body.clone()).exec(env)
}
ElementKind::OutputFormat { name, body } => {
crate::core::output_format_block::OutputFormatBlock::new(name.clone(), body.clone())
.exec(env)
}
ElementKind::Compress(body) => {
crate::core::compressed_block::CompressedBlock::new(body.clone()).exec(env)
}
ElementKind::Setting { key, value } => {
crate::core::property_setting::Setting::new(key.clone(), value.clone()).exec(env)
}
ElementKind::FtlHeader { encoding } => {
crate::core::ftl_header::FtlHeader::new(encoding.clone()).exec(env)
}
ElementKind::TrimLineStart
| ElementKind::NoTrimLineStart
| ElementKind::TrimLineEnd
| ElementKind::LeftTrimLine => crate::core::trim_instruction::TrimMark::new().exec(env),
ElementKind::RawText(t) => crate::core::text_block::RawText::new(t.clone()).exec(env),
ElementKind::Transform { expr, body } => {
crate::core::transform_block::TransformBlock::new(expr.clone(), body.clone()).exec(env)
}
ElementKind::Visit { expr, using } => {
crate::core::visit_node::VisitNode::new(expr.clone(), using.clone()).exec(env)
}
ElementKind::Recurse { expr, using } => {
crate::core::recurse_node::RecurseNode::new(expr.clone(), using.clone()).exec(env)
}
ElementKind::On { expr, body } => {
crate::core::on::On::new(expr.clone(), body.clone()).exec(env)
}
ElementKind::Fallback => {
crate::core::fallback_instruction::FallbackInstruction::new().exec(env)
}
}
}
pub(crate) fn exec_owned(env: &mut crate::core::Environment, el: Element) -> Result<ExecOutcome> {
let span = el.span;
match el.kind {
ElementKind::If { cond, then, else_ } => {
let mut cur_span = span;
let mut cond = cond;
let mut then = then;
let mut else_ = else_;
loop {
let cm = eval::eval(env, &cond).map_err(|e| {
crate::core::environment::attach_location(
e,
&env.current_template_name,
cur_span,
)
})?;
let b = eval::model_to_boolean(env, &cm).map_err(|e| {
crate::core::environment::attach_location(
e,
&env.current_template_name,
cur_span,
)
})?;
if b {
return Ok(ExecOutcome::Next(then));
}
match else_ {
Some(v) if v.len() == 1 => match v.into_iter().next().unwrap() {
Element {
kind:
ElementKind::If {
cond: c2,
then: t2,
else_: e2,
},
span: s2,
} => {
cur_span = s2;
cond = c2;
then = t2;
else_ = e2;
continue;
}
e => return Ok(ExecOutcome::Next(vec![e])),
},
Some(v) => return Ok(ExecOutcome::Next(v)),
None => return Ok(ExecOutcome::Done),
}
}
}
ElementKind::Assignments(els) => {
for e in els {
let outcome = exec_owned(env, e)?;
if !matches!(outcome, ExecOutcome::Done) {
return Ok(outcome);
}
}
Ok(ExecOutcome::Done)
}
ElementKind::Call {
callee,
args,
body,
body_params,
} => {
crate::core::unified_call::exec_call_impl(env, &callee, &args, body, body_params, span)
}
other => exec(env, &Element { kind: other, span }),
}
}
pub(crate) fn exec_setting(
env: &mut crate::core::Environment,
key: &str,
value: &crate::core::Expr,
) -> Result<ExecOutcome> {
let v = {
let m = eval::eval(env, value)?;
if m.is_nothing() {
return Err(TemplateError::invalid_reference(expr_desc(value)));
}
if let Some(b) = &m.boolean {
if b.as_boolean()? {
"true".to_string()
} else {
"false".to_string()
}
} else if let Some(s) = &m.scalar {
s.as_string()?
} else if let Some(n) = &m.number {
n.as_number()?.to_plain_string()
} else {
model_to_string(env, &m)?
}
};
match key {
"locale" => env.settings.to_mut().locale = v,
"number_format" => env.settings.to_mut().number_format = v,
"boolean_format" => {
if v != "c" && !v.contains(',') {
return Err(TemplateError::misc(format!(
"Setting value must be a string that contains two comma-separated values for true and false, or it must be \"c\", but it was {v:?}."
)));
}
env.settings.to_mut().boolean_format = v;
}
"date_format" => env.settings.to_mut().date_format = v,
"time_format" => env.settings.to_mut().time_format = v,
"datetime_format" => env.settings.to_mut().date_time_format = v,
"output_encoding" => env.settings.to_mut().output_encoding = v,
"url_escaping_charset" => env.settings.to_mut().url_escaping_charset = v,
"time_zone" => {
env.settings.to_mut().time_zone = if v == "default" {
env.base_time_zone
} else {
v.parse()
.map_err(|_| TemplateError::misc(format!("Unknown time zone: {v}")))?
};
env.settings.to_mut().time_zone_id = if v == "default" {
env.base_time_zone_id.clone()
} else {
crate::core::configurable::java_time_zone_id(&v)
};
}
"sql_date_and_time_time_zone" => {
}
"classic_compatible" => {
env.settings.to_mut().classic_compatible = parse_bool_setting(&v)?;
}
"whitespace_stripping" => {
env.settings.to_mut().whitespace_stripping = parse_bool_setting(&v)?
}
"strict_syntax" => env.settings.to_mut().strict_syntax = parse_bool_setting(&v)?,
"output_format" => {
env.settings.to_mut().output_format = OutputFormatKind::parse(&v)
.ok_or_else(|| TemplateError::misc(format!("Unknown output format: {v}")))?;
}
"c_format" => {
env.settings.to_mut().c_format = crate::builtins::format::CFormatKind::parse(&v)
.ok_or_else(|| TemplateError::misc(format!("Unknown c_format: {v}")))?;
}
"auto_escaping" => {
env.settings.to_mut().auto_escaping = match v.as_str() {
"on" => crate::core::AutoEscaping::On,
"off" => crate::core::AutoEscaping::Off,
"default" => crate::core::AutoEscaping::Default,
other => {
return Err(TemplateError::misc(format!(
"Invalid auto_escaping value: {other}"
)))
}
};
}
"template_exception_handler" => {
env.settings.to_mut().template_exception_handler = match v.as_str() {
"rethrow" | "debug" | "html_debug" | "ignore" => v,
other => {
return Err(TemplateError::misc(format!(
"Invalid template_exception_handler value: {other}. It must be one of: rethrow, debug, html_debug, ignore"
)))
}
};
}
other => {
return Err(TemplateError::misc(format!("Unsupported setting: {other}")));
}
}
Ok(ExecOutcome::Done)
}
fn parse_bool_setting(v: &str) -> Result<bool> {
match v {
"true" | "yes" | "y" => Ok(true),
"false" | "no" | "n" => Ok(false),
other => Err(TemplateError::misc(format!(
"Invalid boolean value: {other}"
))),
}
}
pub(crate) fn outcome_from_run(r: Result<RunSignal>) -> Result<ExecOutcome> {
match r {
Ok(RunSignal::Completed) => Ok(ExecOutcome::Done),
Ok(RunSignal::Returned(v)) => Ok(ExecOutcome::ReturnValue(v)),
Err(e) => Err(e),
}
}
fn first_newline_end(s: &str) -> usize {
match s.find('\n') {
Some(i) => i + 1,
None => s.len(),
}
}
fn last_newline_start(s: &str) -> usize {
match s.rfind('\n') {
Some(i) => i + 1,
None => s.len(),
}
}
pub(crate) fn strip_text<'a>(
text: &'a str,
strip_before: bool,
strip_after: bool,
env: &crate::core::Environment,
) -> &'a str {
if !env.settings.whitespace_stripping || (!strip_before && !strip_after) {
return text;
}
let begin = if strip_before {
first_newline_end(text)
} else {
0
};
let end = if strip_after {
if text.contains('\n') {
last_newline_start(text)
} else {
0 }
} else {
text.len()
};
&text[begin.min(end)..end]
}
pub(crate) fn get_yes_no(_exp: &crate::core::Expr, s: &str) -> Result<bool> {
let s2 = if s.starts_with('"') && s.len() >= 2 {
&s[1..s.len() - 1]
} else {
s
};
let lower = s2.to_ascii_lowercase();
match lower.as_str() {
"n" | "no" | "f" | "false" => Ok(false),
"y" | "yes" | "t" | "true" => Ok(true),
_ => Err(TemplateError::misc(format!(
"Value must be boolean (or one of these strings: \"n\", \"no\", \"f\", \"false\", \"y\", \"yes\", \"t\", \"true\"), but it was \"{s}\"."
))),
}
}
pub(crate) fn eval_to_string(
env: &mut crate::core::Environment,
e: &crate::core::Expr,
) -> Result<String> {
crate::core::environment::eval_to_string(env, e)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::StringLoader;
use crate::template::{
Configuration, DynValue, ObjectWrapper, SimpleObjectWrapper, TemplateDirectiveBody,
TemplateDirectiveModel,
};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::sync::Arc;
fn cfg() -> (Configuration, Arc<StringLoader>) {
let mut c = Configuration::new();
let loader = Arc::new(StringLoader::default());
c.template_loader = loader.clone();
(c, loader)
}
fn render(
c: &Configuration,
loader: &Arc<StringLoader>,
src: &str,
root: DynValue,
) -> Result<String> {
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let name = format!("t{n}.ftl");
loader.put(&name, src);
let t = c.get_template(&name)?;
let root_model = SimpleObjectWrapper
.wrap(&root)?
.unwrap_or_else(TModel::nothing);
let mut out = Vec::new();
t.process(root_model, &mut out)?;
Ok(String::from_utf8(out).unwrap())
}
fn no_root() -> DynValue {
DynValue::Map(vec![])
}
#[test]
fn if_elseif_else() {
let (c, l) = cfg();
let src = r#"<#if x == 1>one<#elseif x == 2>two<#else>other</#if>"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Int(1))])
)
.unwrap(),
"one"
);
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Int(2))])
)
.unwrap(),
"two"
);
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Int(9))])
)
.unwrap(),
"other"
);
}
#[test]
fn list_loop_with_index_and_sep_else() {
let (c, l) = cfg();
let src = r#"<#list xs as x>${x_index}:${x}<#sep>,</#sep><#else>none</#list>"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![(
"xs".into(),
DynValue::List(vec![DynValue::Int(1), DynValue::Int(2), DynValue::Int(3)])
)])
)
.unwrap(),
"0:1,1:2,2:3"
);
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("xs".into(), DynValue::List(vec![]))])
)
.unwrap(),
"none"
);
}
#[test]
fn list_items_and_else() {
let (c, l) = cfg();
let src = "<#list xs>PRE<#items as x>ITEM</#items><#else>NONE</#list>";
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![(
"xs".into(),
DynValue::List(vec![DynValue::Int(1), DynValue::Int(2)])
)])
)
.unwrap(),
"PREITEMITEM"
);
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("xs".into(), DynValue::List(vec![]))])
)
.unwrap(),
"NONE"
);
}
#[test]
fn list_without_var_items_kv_on_hash() {
let (c, l) = cfg();
let src = r#"<#setting boolean_format="Y,N"><#list m><#items as k, v>${k}=${v};</#items></#list>"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![(
"m".into(),
DynValue::Map(vec![
("a".into(), DynValue::Int(1)),
("b".into(), DynValue::Int(2)),
])
)])
)
.unwrap(),
"a=1;b=2;"
);
let src = "<#list m><#items as k, v>${k}=${v};</#items><#else>Empty</#list>";
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("m".into(), DynValue::Map(vec![]))])
)
.unwrap(),
"Empty"
);
let src = "<#list m><#items as k>${k}</#items></#list>";
let err = render(
&c,
&l,
src,
DynValue::Map(vec![(
"m".into(),
DynValue::Map(vec![("a".into(), DynValue::Int(1))]),
)]),
)
.unwrap_err();
assert!(
err.to_string().contains("must be a sequence or collection"),
"hash with 1-var #items rejected: {err}"
);
}
#[test]
fn list_collection_and_range() {
let (c, l) = cfg();
assert_eq!(
render(&c, &l, "<#list 1..3 as i>${i}</#list>", no_root()).unwrap(),
"123"
);
assert_eq!(
render(
&c,
&l,
"<#list (1..*5) as i>${i}<#if i?has_next>,</#if></#list>",
no_root()
)
.unwrap(),
"1,2,3,4,5"
);
}
#[test]
fn assign_8_operators() {
let (c, l) = cfg();
let src = r#"<#assign x = 5><#assign x += 2><#assign x -= 1><#assign x *= 3><#assign x /= 2><#assign x %= 3>${x}"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "0");
let src = r#"<#assign y = 1><#assign y ++>${y}<#assign y -->${y}"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "21");
let src = r#"<#assign s = "a"><#assign s += "b">${s}"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "ab");
}
#[test]
fn global_and_local() {
let (c, l) = cfg();
let src = r#"<#global g = 1><#macro m><#local g = 2>${g}</#macro><@m/>${g}"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "21");
}
#[test]
fn macro_default_nested_return() {
let (c, l) = cfg();
let src = r#"<#macro greet name="world">Hello ${name}!<#nested></#macro>
<@greet>!</@greet>
<@greet name="rust"/>
<#function double x><#return x * 2></#function>
${double(21)}"#;
let out = render(&c, &l, src, no_root()).unwrap();
assert_eq!(out, "Hello world!!\nHello rust!42");
}
#[test]
fn macro_catch_all_and_positional() {
let (c, l) = cfg();
let src = r#"<#macro m a b...>${a}|${b?join(",")}</#macro><@m 1 2 3/>"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "1|2,3");
let src = r#"<#macro n a=1 rest...>${a}:${rest?keys?join(",")}</#macro><@n x=9 a=7/>"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "7:x");
}
#[test]
fn macro_missing_required_param_errors() {
let (c, l) = cfg();
let err = render(&c, &l, r#"<#macro m a>${a}</#macro><@m/>"#, no_root()).unwrap_err();
assert!(err.to_string().contains("required parameter"), "{err}");
}
#[test]
fn nested_parameter_binding() {
let (c, l) = cfg();
let src = r#"<#macro m><#nested 42></#macro><@m ; bp>got=${bp}</@m>"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "got=42");
}
#[test]
fn switch_fallthrough_and_default() {
let (c, l) = cfg();
let src = r#"<#switch x><#case 1>one<#case 2>two<#default>other</#switch>"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Int(1))])
)
.unwrap(),
"onetwoother"
);
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Int(9))])
)
.unwrap(),
"other"
);
}
#[test]
fn attempt_recover() {
let (c, l) = cfg();
let src = r#"<#attempt>before${missing}after<#recover>caught</#attempt>done"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "caughtdone");
}
#[test]
fn include_and_import() {
let (c, l) = cfg();
l.put("sub/part.ftl", "part:${x}");
l.put("main.ftl", r#"<#include "sub/part.ftl">"#);
let t = c.get_template("main.ftl").unwrap();
let root = SimpleObjectWrapper
.wrap(&DynValue::Map(vec![("x".into(), DynValue::Int(7))]))
.unwrap()
.unwrap();
let mut out = Vec::new();
t.process(root, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "part:7");
l.put(
"lib.ftl",
r#"<#macro libMsg>lib!</#macro><#assign libVar = 1>"#,
);
let src = r#"<#import "lib.ftl" as lib><@lib.libMsg/>${lib.libVar}"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "lib!1");
}
#[test]
fn escape_html() {
let (c, l) = cfg();
let src = r#"<#escape x as x?html>${x}</#escape>"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Str("<a>&".into()))])
)
.unwrap(),
"<a>&"
);
let src = r#"<#escape x as x?html>${x}<#noescape>${x}</#noescape></#escape>"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("x".into(), DynValue::Str("<a>".into()))])
)
.unwrap(),
"<a><a>"
);
}
#[test]
fn string_interpolation_and_booleans() {
let (c, l) = cfg();
let src = r#"${"msg=" + msg} ${b?c}"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![
("msg".into(), DynValue::Str("hi".into())),
("b".into(), DynValue::Bool(true)),
])
)
.unwrap(),
"msg=hi true"
);
let src = r#"<#setting boolean_format="yes,no">${b}"#;
assert_eq!(
render(
&c,
&l,
src,
DynValue::Map(vec![("b".into(), DynValue::Bool(true))])
)
.unwrap(),
"yes"
);
}
#[test]
fn break_continue_in_loop() {
let (c, l) = cfg();
let src = r#"<#list 1..10 as i><#if i == 3><#break></#if>${i}</#list>"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "12");
let src = r#"<#list 1..4 as i><#if i == 2><#continue></#if>${i}</#list>"#;
assert_eq!(render(&c, &l, src, no_root()).unwrap(), "134");
}
#[test]
fn stop_terminates() {
let (c, l) = cfg();
let err = render(&c, &l, "a<#stop \"boom\">b", no_root()).unwrap_err();
match err {
TemplateError::Stop { message } => {
assert!(
message.as_deref().is_some_and(|m| m.starts_with("boom")),
"{message:?}"
);
}
other => panic!("expected Stop, got {other:?}"),
}
}
#[test]
fn type_mismatch_error() {
let (c, l) = cfg();
let err = render(&c, &l, "<#if 1>y</#if>", no_root()).unwrap_err();
assert!(matches!(err, TemplateError::TypeMismatch { .. }), "{err}");
assert!(err.to_string().contains("boolean"), "{err}");
}
#[test]
fn trim_and_compress() {
let (c, l) = cfg();
assert_eq!(
render(&c, &l, "<#trim> a \n b </#trim>", no_root()).unwrap(),
"a \n b"
);
assert_eq!(
render(
&c,
&l,
"<#compress> a \n\n b \n c </#compress>",
no_root()
)
.unwrap(),
"a\nb\nc"
);
}
#[test]
fn whitespace_stripping_applies() {
let (c, l) = cfg();
let src = "<#if true>\n yes\n</#if>";
assert_eq!(render(&c, &l, src, no_root()).unwrap(), " yes\n");
let src = "<#setting whitespace_stripping=false><#if true>\n yes\n</#if>";
let err = render(&c, &l, src, no_root()).unwrap_err();
assert!(
err.to_string().contains("isn't supported"),
"config-level setting rejected at parse: {err}"
);
}
#[test]
fn custom_directive_via_root() {
let (c, l) = cfg();
let d = TModel::from_directive(UpperDirective);
let mut root_map = IndexMap::new();
root_map.insert("upper".to_string(), d);
let root = TModel::from_hash(root_map);
l.put("t.ftl", r#"<@upper x="hi">body</@upper>"#);
let t = c.get_template("t.ftl").unwrap();
let mut out = Vec::new();
t.process(root, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "HI+body");
}
struct UpperDirective;
impl TemplateDirectiveModel for UpperDirective {
fn execute(
&self,
env: &mut crate::core::Environment,
params: &HashMap<String, TModel>,
_loop_vars: &mut [TModel],
body: Option<&dyn TemplateDirectiveBody>,
) -> Result<()> {
let x = params.get("x").cloned().unwrap_or_else(TModel::nothing);
let s = x.get_scalar()?;
env.emit(&s.to_uppercase())?;
if let Some(b) = body {
env.emit("+")?;
b.render(env)?;
}
Ok(())
}
}
#[test]
fn loop_builtins() {
let (c, l) = cfg();
let src = r#"<#list ["a","b","c"] as x>${x?index}/${x?counter}/${x?is_first?c}/${x?is_last?c}/${x?has_next?c};</#list>"#;
assert_eq!(
render(&c, &l, src, no_root()).unwrap(),
"0/1/true/false/true;1/2/false/false/true;2/3/false/true/false;"
);
}
}
#[cfg(test)]
mod golden {
use crate::cache::StringLoader;
use crate::core::Environment;
use crate::error::{Result, TemplateError};
use crate::template::{Configuration, TModel, TemplateDirectiveBody, TemplateDirectiveModel};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::sync::Arc;
const SUITE_DIR: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../freemarker-test/tests/suite"
);
fn read(src: &str) -> String {
std::fs::read_to_string(src).unwrap_or_else(|e| panic!("cannot read {src}: {e}"))
}
fn strip_license_comment(s: &str) -> String {
let s = s.trim_start();
if let Some(rest) = s.strip_prefix("/*") {
if let Some(i) = rest.find("*/") {
let out = &rest[i + 2..];
return out.strip_prefix('\n').unwrap_or(out).to_string();
}
}
s.to_string()
}
fn render_golden(name: &str, src: &str, root: TModel) -> String {
let mut c = Configuration::new();
let loader = Arc::new(StringLoader::default());
c.template_loader = loader.clone();
loader.put(name, src);
let t = c.get_template(name).unwrap();
let mut out = Vec::new();
t.process(root, &mut out).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn golden_helloworld() {
let src = read(&format!("{SUITE_DIR}/templates/helloworld.ftl"));
let expected =
strip_license_comment(&read(&format!("{SUITE_DIR}/expected/helloworld.txt")));
let mut m = IndexMap::new();
m.insert("exec".to_string(), TModel::from_method(ExecHelloWorld));
let out = render_golden("golden-hello.ftl", &src, TModel::from_hash(m));
assert_eq!(out, expected, "helloworld golden mismatch");
}
struct ExecHelloWorld;
impl crate::template::TemplateMethodModelEx for ExecHelloWorld {
fn exec(&self, _env: &mut Environment, _args: Vec<TModel>) -> Result<TModel> {
Ok(TModel::from_scalar("Hello, world!\n".to_string()))
}
}
#[test]
fn golden_boolean() {
let src = read(&format!("{SUITE_DIR}/templates/boolean.ftl"));
let expected = strip_license_comment(&read(&format!("{SUITE_DIR}/expected/boolean.txt")));
let mut m = IndexMap::new();
m.insert(
"message".to_string(),
TModel::from_scalar("Hello, world!".into()),
);
m.insert("boolean1".to_string(), TModel::from_boolean(false));
m.insert("boolean2".to_string(), TModel::from_boolean(true));
m.insert("boolean3".to_string(), TModel::from_boolean(true));
m.insert("boolean4".to_string(), TModel::from_boolean(true));
m.insert("boolean5".to_string(), TModel::from_boolean(false));
m.insert(
"list1".to_string(),
TModel::from_sequence(vec![
TModel::from_scalar("false".into()),
TModel::from_scalar("0".into()),
TModel::from_boolean(false),
TModel::from_boolean(true),
TModel::from_boolean(true),
TModel::from_boolean(true),
TModel::from_boolean(false),
]),
);
m.insert("list2".to_string(), TModel::from_sequence(vec![]));
m.insert(
"hash1".to_string(),
TModel::from_hash({
let mut h = IndexMap::new();
h.insert(
"temp".to_string(),
TModel::from_scalar("Hello, world.".into()),
);
h.insert("boolean".to_string(), TModel::from_boolean(false));
h
}),
);
m.insert("hash2".to_string(), TModel::from_hash(IndexMap::new()));
m.insert(
"assert".to_string(),
TModel::from_directive(AssertDirective),
);
let out = render_golden("golden-boolean.ftl", &src, TModel::from_hash(m));
assert_eq!(out, expected, "boolean golden mismatch");
}
struct AssertDirective;
impl TemplateDirectiveModel for AssertDirective {
fn execute(
&self,
_env: &mut crate::core::Environment,
params: &HashMap<String, TModel>,
_loop_vars: &mut [TModel],
_body: Option<&dyn TemplateDirectiveBody>,
) -> Result<()> {
let test = params
.get("test")
.ok_or_else(|| TemplateError::misc("Missing required parameter \"test\""))?;
let b = test.eval_boolean()?;
if !b {
return Err(TemplateError::misc("Assertion failed"));
}
Ok(())
}
}
#[test]
fn golden_variables() {
let src = read(&format!("{SUITE_DIR}/templates/variables.ftl"));
let expected = strip_license_comment(&read(&format!("{SUITE_DIR}/expected/variables.txt")));
let mut m = IndexMap::new();
m.insert(
"message".to_string(),
TModel::from_scalar("Hello, world!".into()),
);
let out = render_golden("golden-vars.ftl", &src, TModel::from_hash(m));
assert_eq!(out, expected, "variables golden mismatch");
}
#[test]
fn golden_if() {
let mut src = read(&format!("{SUITE_DIR}/templates/if.ftl"));
if let Some(i) = src.find("<#-- parsing errors -->") {
src.truncate(i);
}
let expected = strip_license_comment(&read(&format!("{SUITE_DIR}/expected/if.txt")));
let mut m = IndexMap::new();
m.insert(
"message".to_string(),
TModel::from_scalar("Hello, world!".into()),
);
let out = render_golden("golden-if.ftl", &src, TModel::from_hash(m));
if out != expected {
std::fs::write("/tmp/if_mine.txt", &out).unwrap();
std::fs::write("/tmp/if_expected.txt", &expected).unwrap();
}
assert_eq!(out, expected, "if golden mismatch");
}
}