#![cfg_attr(docsrs, feature(doc_cfg))]
#![doc = include_str!("../README.md")]
#![cfg_attr(not(feature = "std"), no_std)]
#![forbid(unsafe_code)]
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented,
clippy::unreachable
)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable
)
)]
extern crate alloc;
pub(crate) mod error;
pub(crate) mod exec;
pub(crate) mod funcs;
pub(crate) mod go;
pub mod parse;
pub(crate) mod value;
pub use error::{Result, TemplateError};
use funcs::builtins;
#[cfg(feature = "std")]
fn shared_builtins() -> Arc<BTreeMap<String, ValueFunc>> {
use std::sync::LazyLock;
static BUILTINS: LazyLock<Arc<BTreeMap<String, ValueFunc>>> =
LazyLock::new(|| Arc::new(builtins()));
BUILTINS.clone()
}
#[cfg(not(feature = "std"))]
fn shared_builtins() -> Arc<BTreeMap<String, ValueFunc>> {
Arc::new(builtins())
}
fn col_for_offset(src: &str, offset: usize) -> usize {
let end = offset.min(src.len());
let line_start = src[..end].rfind('\n').map_or(0, |i| i + 1);
src[line_start..end].chars().count() + 1
}
pub use go::{html_escape, js_escape, url_encode};
pub use value::{ToValue, Value, ValueFunc};
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::vec::Vec;
#[cfg(feature = "std")]
use std::io::Write;
use exec::Executor;
pub use exec::MissingKey;
use parse::{DefineNode, ListNode, Parser};
pub type FuncMap = BTreeMap<String, ValueFunc>;
pub struct Template {
name: String,
tree: Option<ListNode>,
defines: BTreeMap<String, Arc<ListNode>>,
funcs: Arc<BTreeMap<String, ValueFunc>>,
left_delim: String,
right_delim: String,
missing_key: MissingKey,
max_range_iters: u64,
}
#[cfg(feature = "std")]
struct IoAdapter<'a, W> {
inner: &'a mut W,
error: Option<std::io::Error>,
}
#[cfg(feature = "std")]
impl<'a, W> IoAdapter<'a, W> {
fn new(inner: &'a mut W) -> Self {
IoAdapter { inner, error: None }
}
fn err_mapper(self) -> impl FnOnce(TemplateError) -> TemplateError {
move |e| match e {
error::TemplateError::Write => error::TemplateError::Io(
self.error
.unwrap_or_else(|| std::io::Error::other("write error")),
),
_ => e,
}
}
}
#[cfg(feature = "std")]
impl<W: std::io::Write> core::fmt::Write for IoAdapter<'_, W> {
fn write_str(&mut self, s: &str) -> core::fmt::Result {
self.inner.write_all(s.as_bytes()).map_err(|e| {
self.error = Some(e);
core::fmt::Error
})
}
}
impl Template {
pub fn new(name: &str) -> Self {
Template {
name: name.to_string(),
tree: None,
defines: BTreeMap::new(),
funcs: shared_builtins(),
left_delim: "{{".to_string(),
right_delim: "}}".to_string(),
missing_key: MissingKey::default(),
max_range_iters: exec::DEFAULT_MAX_RANGE_ITERS,
}
}
#[must_use]
pub fn max_range_iters(mut self, n: u64) -> Self {
self.max_range_iters = n;
self
}
#[must_use]
pub fn delims(mut self, left: &str, right: &str) -> Self {
self.left_delim = left.to_string();
self.right_delim = right.to_string();
self
}
#[must_use]
pub fn missing_key(mut self, mk: MissingKey) -> Self {
self.missing_key = mk;
self
}
#[must_use]
pub fn func(
mut self,
name: &str,
f: impl Fn(&[Value]) -> Result<Value> + Send + Sync + 'static,
) -> Self {
Arc::make_mut(&mut self.funcs).insert(name.to_string(), Arc::new(f));
self
}
#[must_use]
pub fn funcs(mut self, func_map: FuncMap) -> Self {
Arc::make_mut(&mut self.funcs).extend(func_map);
self
}
pub fn parse(mut self, src: &str) -> Result<Self> {
let parser =
Parser::with_name(self.parse_name(), src, &self.left_delim, &self.right_delim)?;
let (tree, defines) = parser.parse()?;
Self::check_in_file_basename_collision(&self.name, &tree, &defines, src)?;
if !self.name.is_empty() && !tree.is_empty_tree() {
self.defines
.insert(self.name.clone(), Arc::new(tree.clone()));
}
if self.tree.is_none() || !tree.is_empty_tree() {
self.tree = Some(tree);
}
self.merge_defines(defines, src)?;
self.sync_tree_to_own_name();
Ok(self)
}
fn parse_name(&self) -> Option<&str> {
(!self.name.is_empty()).then_some(self.name.as_str())
}
fn merge_defines(&mut self, defines: Vec<DefineNode>, src: &str) -> Result<()> {
let mut seen_non_empty: alloc::collections::BTreeSet<crate::parse::SmolStr> =
alloc::collections::BTreeSet::new();
for def in defines {
let new_is_empty = def.body.is_empty_tree();
if seen_non_empty.contains(&def.name) {
if new_is_empty {
continue;
}
return Err(error::TemplateError::Parse {
name: self.parse_name().map(String::from),
line: def.pos.line,
col: col_for_offset(src, def.pos.offset),
message: alloc::format!(
"multiple definition of template {:?}",
def.name.as_str()
),
});
}
if new_is_empty && self.defines.contains_key(def.name.as_str()) {
continue;
}
if !new_is_empty {
seen_non_empty.insert(def.name.clone());
}
self.defines
.insert(def.name.to_string(), Arc::new(def.body));
}
Ok(())
}
fn associate_body(&mut self, name: &str, body: ListNode) {
if body.is_empty_tree() && self.defines.contains_key(name) {
return;
}
self.defines.insert(name.to_string(), Arc::new(body));
}
fn check_in_file_basename_collision(
name: &str,
top: &ListNode,
defines: &[DefineNode],
src: &str,
) -> Result<()> {
if name.is_empty() || top.is_empty_tree() {
return Ok(());
}
if let Some(def) = defines
.iter()
.find(|d| d.name.as_str() == name && !d.body.is_empty_tree())
{
return Err(error::TemplateError::Parse {
name: Some(name.to_string()),
line: def.pos.line,
col: col_for_offset(src, def.pos.offset),
message: alloc::format!("multiple definition of template {name:?}"),
});
}
Ok(())
}
fn sync_tree_to_own_name(&mut self) {
if self.name.is_empty() {
return;
}
if let Some(entry) = self.defines.get(self.name.as_str()) {
self.tree = Some((**entry).clone());
}
}
#[cfg(feature = "std")]
pub fn parse_files(mut self, filenames: &[&str]) -> Result<Self> {
if filenames.is_empty() {
return Err(error::TemplateError::NoFiles);
}
for filename in filenames {
let content =
std::fs::read_to_string(filename).map_err(|e| error::TemplateError::ReadFile {
path: filename.to_string(),
source: e,
})?;
let basename = std::path::Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
let parser = Parser::with_name(
Some(basename),
&content,
&self.left_delim,
&self.right_delim,
)?;
let (tree, defines) = parser.parse()?;
Self::check_in_file_basename_collision(basename, &tree, &defines, &content)?;
if basename == self.name && (self.tree.is_none() || !tree.is_empty_tree()) {
self.tree = Some(tree.clone());
}
self.associate_body(basename, tree);
self.merge_defines(defines, &content)?;
if basename == self.name {
self.sync_tree_to_own_name();
}
}
Ok(self)
}
#[must_use]
pub fn add_parse_tree(mut self, name: &str, tree: ListNode) -> Self {
self.defines.insert(name.to_string(), Arc::new(tree));
self
}
pub fn execute_fmt<W: core::fmt::Write>(&self, writer: &mut W, data: &Value) -> Result<()> {
let tree = self.tree.as_ref().ok_or_else(|| {
error::TemplateError::Exec(format!("template {:?} has not been parsed", self.name))
})?;
let mut executor = Executor::new(&self.funcs, &self.defines);
executor.set_missing_key(self.missing_key);
executor.set_max_range_iters(self.max_range_iters);
executor.execute(writer, tree, data)
}
pub fn execute_template_fmt<W: core::fmt::Write>(
&self,
writer: &mut W,
name: &str,
data: &Value,
) -> Result<()> {
let tree = self
.defines
.get(name)
.ok_or_else(|| error::TemplateError::UndefinedTemplate(name.to_string()))?;
let mut executor = Executor::new(&self.funcs, &self.defines);
executor.set_missing_key(self.missing_key);
executor.set_max_range_iters(self.max_range_iters);
executor.execute(writer, tree.as_ref(), data)
}
#[cfg(feature = "std")]
pub fn execute<W: Write>(&self, writer: &mut W, data: &Value) -> Result<()> {
let mut adapter = IoAdapter::new(writer);
self.execute_fmt(&mut adapter, data)
.map_err(adapter.err_mapper())
}
#[cfg(feature = "std")]
pub fn execute_template<W: Write>(
&self,
writer: &mut W,
name: &str,
data: &Value,
) -> Result<()> {
let mut adapter = IoAdapter::new(writer);
self.execute_template_fmt(&mut adapter, name, data)
.map_err(adapter.err_mapper())
}
pub fn execute_to_string(&self, data: &Value) -> Result<String> {
let mut buf = String::new();
self.execute_fmt(&mut buf, data)?;
Ok(buf)
}
pub fn execute_template_to_string(&self, name: &str, data: &Value) -> Result<String> {
let mut buf = String::new();
self.execute_template_fmt(&mut buf, name, data)?;
Ok(buf)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn lookup(&self, name: &str) -> Option<&ListNode> {
self.defines.get(name).map(Arc::as_ref)
}
pub fn templates(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.defines.keys().map(|s| s.as_str()).collect();
names.sort_unstable();
names
}
pub fn defined_templates(&self) -> String {
if self.defines.is_empty() {
return String::new();
}
let mut names: Vec<&str> = self.defines.keys().map(|s| s.as_str()).collect();
names.sort_unstable();
let quoted: Vec<String> = names.iter().map(|n| format!("{n:?}")).collect();
format!("; defined templates are: {}", quoted.join(", "))
}
}
impl Clone for Template {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
tree: self.tree.clone(),
defines: self.defines.clone(),
funcs: self.funcs.clone(),
left_delim: self.left_delim.clone(),
right_delim: self.right_delim.clone(),
missing_key: self.missing_key,
max_range_iters: self.max_range_iters,
}
}
}
pub fn execute(template_src: &str, data: &Value) -> Result<String> {
Template::new("")
.parse(template_src)?
.execute_to_string(data)
}
#[cfg(feature = "std")]
pub fn execute_file(filename: &str, data: &Value) -> Result<String> {
let basename = std::path::Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(filename);
Template::new(basename)
.parse_files(&[filename])?
.execute_to_string(data)
}
pub fn is_true(val: &Value) -> bool {
val.is_truthy()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ToValue;
use alloc::vec;
#[test]
fn test_simple_api() {
let result = execute("Hello, {{.Name}}!", &tmap! { "Name" => "World" }).unwrap();
assert_eq!(result, "Hello, World!");
}
#[test]
fn test_custom_func() {
let result = Template::new("test")
.func("upper", |args| {
if let Some(Value::String(s)) = args.first() {
Ok(Value::String(s.to_uppercase().into()))
} else {
Ok(Value::Nil)
}
})
.parse("{{.Name | upper}}")
.unwrap()
.execute_to_string(&tmap! { "Name" => "hello" })
.unwrap();
assert_eq!(result, "HELLO");
}
#[test]
fn test_custom_delims() {
let result = Template::new("test")
.delims("<%", "%>")
.parse("Hello, <%.Name%>!")
.unwrap()
.execute_to_string(&tmap! { "Name" => "World" })
.unwrap();
assert_eq!(result, "Hello, World!");
}
#[test]
fn test_complex_template() {
let data = tmap! {
"Title" => "Users",
"Users" => vec![
tmap! { "Name" => "Alice", "Age" => 30i64 }.to_value(),
tmap! { "Name" => "Bob", "Age" => 25i64 }.to_value(),
].to_value(),
};
let tmpl = r#"# {{.Title}}
{{range .Users}}- {{.Name}} ({{.Age}})
{{end}}"#;
let result = execute(tmpl, &data).unwrap();
assert_eq!(result, "# Users\n- Alice (30)\n- Bob (25)\n");
}
#[test]
fn test_template_inheritance() {
let data = tmap! { "Content" => "Hello!" };
let result = Template::new("page")
.parse(r#"{{define "base"}}<html>{{template "body" .}}</html>{{end}}{{define "body"}}<p>{{.Content}}</p>{{end}}{{template "base" .}}"#)
.unwrap()
.execute_to_string(&data)
.unwrap();
assert_eq!(result, "<html><p>Hello!</p></html>");
}
#[test]
fn test_pipeline_chaining() {
let data = tmap! {
"Items" => vec!["a".to_string(), "bb".to_string(), "ccc".to_string()],
};
let result = execute("{{.Items | len | printf \"%d items\"}}", &data).unwrap();
assert_eq!(result, "3 items");
}
#[test]
fn test_comparison() {
let data = tmap! { "Score" => 85i64 };
let result = execute("{{if gt .Score 80}}pass{{else}}fail{{end}}", &data).unwrap();
assert_eq!(result, "pass");
}
#[test]
fn test_range_with_index() {
let data = tmap! {
"Items" => vec!["a".to_string(), "b".to_string()],
};
let result = execute("{{range $i, $v := .Items}}{{$i}}:{{$v}} {{end}}", &data);
assert!(result.is_ok());
}
#[test]
fn test_dollar_variable() {
let data = tmap! {
"Name" => "outer",
"Items" => vec!["inner".to_string()],
};
let result = execute("{{range .Items}}{{$}} {{.}}{{end}}", &data);
assert!(result.is_ok());
}
#[test]
fn test_missingkey_error() {
let data = tmap! { "X" => 1i64 };
let result = Template::new("test")
.missing_key(MissingKey::Error)
.parse("{{.Missing}}")
.unwrap()
.execute_to_string(&data);
assert!(result.is_err());
}
#[test]
fn test_missingkey_default() {
let data = tmap! { "X" => 1i64 };
let result = Template::new("test")
.parse("{{.Missing}}")
.unwrap()
.execute_to_string(&data)
.unwrap();
assert_eq!(result, "<no value>");
}
#[test]
fn test_execute_template() {
let tmpl = Template::new("root")
.parse(r#"{{define "a"}}hello{{end}}{{define "b"}}world{{end}}main"#)
.unwrap();
assert_eq!(
tmpl.execute_template_to_string("a", &Value::Nil).unwrap(),
"hello"
);
assert_eq!(
tmpl.execute_template_to_string("b", &Value::Nil).unwrap(),
"world"
);
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "main");
}
#[test]
fn test_execute_template_undefined() {
let tmpl = Template::new("t").parse("hello").unwrap();
let err = tmpl.execute_template_to_string("nope", &Value::Nil);
assert!(err.is_err());
}
#[test]
fn test_lookup() {
let tmpl = Template::new("t")
.parse(r#"{{define "x"}}...{{end}}"#)
.unwrap();
assert!(tmpl.lookup("x").is_some());
assert!(tmpl.lookup("y").is_none());
}
#[test]
fn test_templates_list() {
let tmpl = Template::new("t")
.parse(r#"{{define "b"}}...{{end}}{{define "a"}}...{{end}}"#)
.unwrap();
assert_eq!(tmpl.templates(), vec!["a", "b"]);
}
#[test]
fn test_defined_templates() {
let tmpl = Template::new("t")
.parse(r#"{{define "header"}}...{{end}}{{define "footer"}}...{{end}}"#)
.unwrap();
let s = tmpl.defined_templates();
assert!(s.contains("\"header\""));
assert!(s.contains("\"footer\""));
}
#[test]
fn test_defined_templates_lists_receiver() {
let tmpl = Template::new("t").parse("hello").unwrap();
assert_eq!(tmpl.defined_templates(), r#"; defined templates are: "t""#);
}
#[test]
fn test_clone_template() {
let original = Template::new("t")
.parse(r#"{{define "x"}}original{{end}}{{template "x"}}"#)
.unwrap();
let cloned = original.clone().add_parse_tree(
"x",
ListNode {
pos: parse::Pos::new(0, 1),
nodes: vec![parse::Node::Text(parse::TextNode {
pos: parse::Pos::new(0, 1),
text: "cloned".into(),
})],
},
);
assert_eq!(original.execute_to_string(&Value::Nil).unwrap(), "original");
assert_eq!(cloned.execute_to_string(&Value::Nil).unwrap(), "cloned");
}
#[test]
fn test_add_parse_tree() {
let tmpl = Template::new("t")
.parse(r#"{{template "injected"}}"#)
.unwrap()
.add_parse_tree(
"injected",
ListNode {
pos: parse::Pos::new(0, 1),
nodes: vec![parse::Node::Text(parse::TextNode {
pos: parse::Pos::new(0, 1),
text: "works".into(),
})],
},
);
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "works");
}
#[test]
fn test_funcs_bulk() {
let mut fm = FuncMap::new();
fm.insert(
"greet".into(),
Arc::new(|args: &[Value]| Ok(Value::String(format!("Hi, {}!", args[0]).into()))),
);
let result = Template::new("t")
.funcs(fm)
.parse(r#"{{greet "World"}}"#)
.unwrap()
.execute_to_string(&tmap! {})
.unwrap();
assert_eq!(result, "Hi, World!");
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files");
let _ = std::fs::create_dir_all(&dir);
let header = dir.join("header.html");
let footer = dir.join("footer.html");
std::fs::File::create(&header)
.unwrap()
.write_all(b"{{define \"header\"}}<h1>{{.Title}}</h1>{{end}}")
.unwrap();
std::fs::File::create(&footer)
.unwrap()
.write_all(b"{{define \"footer\"}}<footer>bye</footer>{{end}}")
.unwrap();
let h = header.to_str().unwrap();
let f = footer.to_str().unwrap();
let tmpl = Template::new("page")
.parse(r#"{{template "header" .}}{{template "footer" .}}"#)
.unwrap()
.parse_files(&[h, f])
.unwrap();
let data = tmap! { "Title" => "Hello" };
let result = tmpl.execute_to_string(&data).unwrap();
assert_eq!(result, "<h1>Hello</h1><footer>bye</footer>");
assert!(tmpl.lookup("header.html").is_some());
assert!(tmpl.lookup("footer.html").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_not_found() {
let result = Template::new("t").parse_files(&["/nonexistent/file.html"]);
let err = result.err().unwrap();
assert!(
matches!(
err,
error::TemplateError::ReadFile { ref path, .. }
if path == "/nonexistent/file.html"
),
"expected ReadFile error, got {:?}",
err
);
}
#[test]
#[cfg(feature = "std")]
fn test_execute_file() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_execute_file");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("greeting.tmpl");
std::fs::File::create(&path)
.unwrap()
.write_all(b"Hello, {{.Name}}!")
.unwrap();
let data = tmap! { "Name" => "World" };
let result = execute_file(path.to_str().unwrap(), &data).unwrap();
assert_eq!(result, "Hello, World!");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_basename_matches_receiver() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files_basename");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("main.tmpl");
std::fs::File::create(&path)
.unwrap()
.write_all(b"Hi {{.Name}}")
.unwrap();
let tmpl = Template::new("main.tmpl")
.parse_files(&[path.to_str().unwrap()])
.unwrap();
let data = tmap! { "Name" => "there" };
assert_eq!(tmpl.execute_to_string(&data).unwrap(), "Hi there");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_error_cites_filename() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files_error_name");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("broken.tmpl");
std::fs::File::create(&path)
.unwrap()
.write_all(b"ok\n{{if}}missing pipeline{{end}}")
.unwrap();
let err = Template::new("t")
.parse_files(&[path.to_str().unwrap()])
.err()
.unwrap();
match &err {
error::TemplateError::Parse { name, line, .. } => {
assert_eq!(name.as_deref(), Some("broken.tmpl"));
assert_eq!(*line, 2);
}
other => panic!("expected Parse error, got {other:?}"),
}
let s = err.to_string();
assert!(
s.starts_with("template: broken.tmpl:"),
"unexpected display: {s}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_parse_error_without_name_drops_name_segment() {
let err = Template::new("").parse("{{if}}").err().unwrap();
let s = err.to_string();
assert!(s.starts_with("template: "), "got: {s}");
assert!(!s.contains("template: :"), "should drop name segment: {s}");
}
#[test]
fn test_lex_error_without_name_drops_name_segment() {
let err = Template::new("").parse("{{\"unterminated}}").err().unwrap();
match &err {
error::TemplateError::Lex { name, .. } => {
assert!(name.is_none(), "expected name to be None, got {name:?}");
}
other => panic!("expected Lex error, got {other:?}"),
}
let s = err.to_string();
assert!(s.starts_with("template: "), "got: {s}");
assert!(!s.contains("template: :"), "should drop name segment: {s}");
}
#[test]
fn test_lex_error_carries_line_col_and_shared_format() {
let err = Template::new("t")
.parse("ok\n{{\"unterminated}}")
.err()
.unwrap();
match &err {
error::TemplateError::Lex {
name,
line,
col,
message,
} => {
assert_eq!(name.as_deref(), Some("t"));
assert_eq!(*line, 2);
assert!(*col >= 1);
assert!(!message.is_empty());
}
other => panic!("expected Lex error, got {other:?}"),
}
assert!(err.to_string().starts_with("template: t:2:"));
}
#[test]
fn test_parse_error_tagged_with_receiver_name() {
let err = Template::new("greet.tmpl").parse("{{if}}").err().unwrap();
match &err {
error::TemplateError::Parse { name, .. } => {
assert_eq!(name.as_deref(), Some("greet.tmpl"));
}
other => panic!("expected Parse error, got {other:?}"),
}
assert!(err.to_string().starts_with("template: greet.tmpl:"));
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_empty() {
let result = Template::new("t").parse_files(&[]);
let err = result.err().unwrap();
assert!(
matches!(err, error::TemplateError::NoFiles),
"expected NoFiles error, got {:?}",
err
);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_in_file_duplicate_define_errors() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files_dup_in_file");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("dup.tmpl");
std::fs::File::create(&path)
.unwrap()
.write_all(br#"{{define "x"}}first{{end}}{{define "x"}}second{{end}}"#)
.unwrap();
let err = Template::new("t")
.parse_files(&[path.to_str().unwrap()])
.err()
.expect("expected multiple-definition error");
assert!(
err.to_string()
.contains(r#"multiple definition of template "x""#),
"unexpected error message: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_across_files_non_empty_replaces() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files_across_replace");
let _ = std::fs::create_dir_all(&dir);
let a = dir.join("a.tmpl");
let b = dir.join("b.tmpl");
std::fs::File::create(&a)
.unwrap()
.write_all(br#"{{define "x"}}first{{end}}"#)
.unwrap();
std::fs::File::create(&b)
.unwrap()
.write_all(br#"{{define "x"}}second{{end}}"#)
.unwrap();
let tmpl = Template::new("t")
.parse(r#"{{template "x"}}"#)
.unwrap()
.parse_files(&[a.to_str().unwrap(), b.to_str().unwrap()])
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "second");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_empty_define_does_not_clobber() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files_empty_clobber");
let _ = std::fs::create_dir_all(&dir);
let a = dir.join("a.tmpl");
let b = dir.join("b.tmpl");
std::fs::File::create(&a)
.unwrap()
.write_all(br#"{{define "x"}}content{{end}}"#)
.unwrap();
std::fs::File::create(&b)
.unwrap()
.write_all(br#"{{define "x"}}{{end}}"#)
.unwrap();
let tmpl = Template::new("t")
.parse(r#"{{template "x"}}"#)
.unwrap()
.parse_files(&[a.to_str().unwrap(), b.to_str().unwrap()])
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "content");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_parse_define_name_matches_receiver_syncs_tree() {
let tmpl = Template::new("X")
.parse(r#"{{define "X"}}body{{end}}"#)
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "body");
assert_eq!(
tmpl.execute_template_to_string("X", &Value::Nil).unwrap(),
"body"
);
}
#[test]
fn test_parse_toplevel_and_empty_same_name_define_keeps_toplevel() {
let tmpl = Template::new("X")
.parse(r#"toplevel{{define "X"}}{{end}}"#)
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "toplevel");
assert_eq!(
tmpl.execute_template_to_string("X", &Value::Nil).unwrap(),
"toplevel"
);
}
#[test]
fn test_parse_second_call_toplevel_replaces_prior_same_name_define() {
let tmpl = Template::new("X")
.parse(r#"{{define "X"}}body{{end}}"#)
.unwrap()
.parse("newtop")
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "newtop");
assert_eq!(
tmpl.execute_template_to_string("X", &Value::Nil).unwrap(),
"newtop"
);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_toplevel_and_empty_same_name_define_keeps_toplevel() {
use std::io::Write as _;
let dir =
std::env::temp_dir().join("gotmpl_test_parse_files_toplevel_empty_define_keeps_top");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("X");
std::fs::File::create(&path)
.unwrap()
.write_all(br#"toplevel{{define "X"}}{{end}}"#)
.unwrap();
let tmpl = Template::new("X")
.parse_files(&[path.to_str().unwrap()])
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "toplevel");
assert_eq!(
tmpl.execute_template_to_string("X", &Value::Nil).unwrap(),
"toplevel"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_parse_toplevel_and_same_name_define_errors() {
let err = Template::new("X")
.parse(r#"toplevel {{define "X"}}body{{end}}"#)
.err()
.expect("expected multiple-definition error");
assert!(
err.to_string()
.contains(r#"multiple definition of template "X""#),
"unexpected error message: {err}"
);
}
#[test]
#[cfg(feature = "std")]
fn test_parse_files_define_name_matches_basename_syncs_tree() {
use std::io::Write as _;
let dir = std::env::temp_dir().join("gotmpl_test_parse_files_basename_define_sync");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("X");
std::fs::File::create(&path)
.unwrap()
.write_all(br#"{{define "X"}}body{{end}}"#)
.unwrap();
let tmpl = Template::new("X")
.parse_files(&[path.to_str().unwrap()])
.unwrap();
assert_eq!(tmpl.execute_to_string(&Value::Nil).unwrap(), "body");
assert_eq!(
tmpl.execute_template_to_string("X", &Value::Nil).unwrap(),
"body"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("<b>hi</b>"), "<b>hi</b>");
assert_eq!(html_escape("a&b"), "a&b");
}
#[test]
fn test_js_escape() {
assert_eq!(js_escape("a'b"), "a\\'b");
}
#[test]
fn test_url_encode() {
assert_eq!(url_encode("hello world"), "hello+world");
}
#[test]
fn test_is_true() {
assert!(is_true(&Value::Bool(true)));
assert!(!is_true(&Value::Bool(false)));
assert!(!is_true(&Value::Int(0)));
assert!(is_true(&Value::Int(1)));
assert!(!is_true(&Value::Nil));
}
}