use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use minijinja::Environment;
use nerpa_core::Value as CoreValue;
const FUEL: u64 = 2_000_000;
const DEPTH: usize = 32;
const METHODS: [&str; 17] = [
"items",
"keys",
"values",
"get",
"upper",
"lower",
"title",
"capitalize",
"strip",
"lstrip",
"rstrip",
"split",
"splitlines",
"replace",
"startswith",
"endswith",
"join",
];
pub trait Templates: std::fmt::Debug + 'static {
fn get(&self, name: &str) -> Result<Option<String>, String>;
}
#[derive(Debug, Clone)]
pub struct Directory {
root: PathBuf,
}
impl Directory {
#[must_use]
pub fn at(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
impl Templates for Directory {
fn get(&self, name: &str) -> Result<Option<String>, String> {
let path = Path::new(name);
if path.is_absolute() {
return Err(format!(
"{name:?} is an absolute path; templates are named relative to the configuration"
));
}
if path
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return Err(format!(
"{name:?} leaves the configuration's directory, and a template it does not contain is not part of it"
));
}
let full = self.root.join(path);
match std::fs::read_to_string(&full) {
Ok(text) => {
let inside = full
.canonicalize()
.ok()
.zip(self.root.canonicalize().ok())
.is_some_and(|(reached, root)| reached.starts_with(root));
if inside {
Ok(Some(text))
} else {
Err(format!(
"{name:?} leads outside the configuration's directory"
))
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("{}: {error}", full.display())),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Held {
entries: BTreeMap<String, String>,
}
impl Held {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn holding(mut self, name: impl Into<String>, text: impl Into<String>) -> Self {
self.entries.insert(name.into(), text.into());
self
}
}
impl Templates for Held {
fn get(&self, name: &str) -> Result<Option<String>, String> {
Ok(self.entries.get(name).cloned())
}
}
#[dacc_derive::doc_anchor(id = "inv-template-001")]
fn allowed(environment: &mut Environment<'_>) {
use minijinja::{filters, tests};
environment.add_filter("abs", filters::abs);
environment.add_filter("attr", filters::attr);
environment.add_filter("batch", filters::batch);
environment.add_filter("capitalize", filters::capitalize);
environment.add_filter("default", filters::default);
environment.add_filter("first", filters::first);
environment.add_filter("float", filters::float);
environment.add_filter("indent", filters::indent);
environment.add_filter("int", filters::int);
environment.add_filter("items", filters::items);
environment.add_filter("join", filters::join);
environment.add_filter("last", filters::last);
environment.add_filter("length", filters::length);
environment.add_filter("lines", filters::lines);
environment.add_filter("list", filters::list);
environment.add_filter("lower", filters::lower);
environment.add_filter("map", filters::map);
environment.add_filter("max", filters::max);
environment.add_filter("min", filters::min);
environment.add_filter("reject", filters::reject);
environment.add_filter("rejectattr", filters::rejectattr);
environment.add_filter("replace", filters::replace);
environment.add_filter("reverse", filters::reverse);
environment.add_filter("round", filters::round);
environment.add_filter("select", filters::select);
environment.add_filter("selectattr", filters::selectattr);
environment.add_filter("slice", filters::slice);
environment.add_filter("sort", filters::sort);
environment.add_filter("title", filters::title);
environment.add_filter("trim", filters::trim);
environment.add_filter("unique", filters::unique);
environment.add_filter("upper", filters::upper);
environment.add_test("boolean", tests::is_boolean);
environment.add_test("defined", tests::is_defined);
environment.add_test("even", tests::is_even);
environment.add_test("false", tests::is_false);
environment.add_test("integer", tests::is_integer);
environment.add_test("mapping", tests::is_mapping);
environment.add_test("none", tests::is_none);
environment.add_test("number", tests::is_number);
environment.add_test("odd", tests::is_odd);
environment.add_test("sequence", tests::is_sequence);
environment.add_test("string", tests::is_string);
environment.add_test("true", tests::is_true);
environment.add_test("undefined", tests::is_undefined);
environment.add_global(
"range",
minijinja::Value::from_function(minijinja::functions::range),
);
}
pub(crate) struct Renderer {
environment: Environment<'static>,
source: Box<dyn Templates>,
}
impl std::fmt::Debug for Renderer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Renderer").finish_non_exhaustive()
}
}
impl Renderer {
pub(crate) fn new(source: Box<dyn Templates>) -> Self {
let mut environment = Environment::empty();
allowed(&mut environment);
environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
environment.set_keep_trailing_newline(true);
environment.set_fuel(Some(FUEL));
environment.set_recursion_limit(DEPTH);
environment.set_unknown_method_callback(|state, value, method, arguments| {
if !METHODS.contains(&method) {
return Err(minijinja::Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("{method} is not one of the methods nerpa offers"),
));
}
minijinja_contrib::pycompat::unknown_method_callback(state, value, method, arguments)
});
Self {
environment,
source,
}
}
pub(crate) fn render(
&self,
name: &str,
values: &BTreeMap<String, CoreValue>,
) -> Result<String, String> {
let Some(text) = self.source.get(name)? else {
return Err(format!("there is no template called {name:?}"));
};
let context: BTreeMap<&str, minijinja::Value> = values
.iter()
.map(|(key, value)| Ok((key.as_str(), as_minijinja(value)?)))
.collect::<Result<_, String>>()?;
self.environment
.render_str(&text, minijinja::Value::from(context))
.map_err(|error| describe(name, &error))
}
}
fn describe(name: &str, error: &minijinja::Error) -> String {
use std::fmt::Write as _;
let mut message = format!("{name}: {error}");
let mut cause = std::error::Error::source(error);
while let Some(next) = cause {
let _ = write!(message, ": {next}");
cause = std::error::Error::source(next);
}
message
}
fn as_minijinja(value: &CoreValue) -> Result<minijinja::Value, String> {
Ok(match value {
CoreValue::Text(text) => minijinja::Value::from(text.as_str()),
CoreValue::Integer(number) => minijinja::Value::from(*number),
CoreValue::Boolean(flag) => minijinja::Value::from(*flag),
CoreValue::List(items) => minijinja::Value::from(
items
.iter()
.map(as_minijinja)
.collect::<Result<Vec<_>, _>>()?,
),
CoreValue::Map(entries) => minijinja::Value::from(
entries
.iter()
.map(|(key, item)| Ok((key.as_str(), as_minijinja(item)?)))
.collect::<Result<BTreeMap<_, _>, String>>()?,
),
CoreValue::Bytes(bytes) => minijinja::Value::from_bytes(bytes.clone()),
other => {
return Err(format!(
"a value of this kind cannot be given to a template: {other}"
));
}
})
}