use std::borrow::Cow;
use std::sync::Arc;
use minijinja::{AutoEscape, Environment, Error, ErrorKind, UndefinedBehavior, Value};
use crate::clock::Clock;
use crate::error::RAISE_SENTINEL;
use crate::json::tojson_filter;
#[derive(Clone)]
pub(crate) struct EngineConfig {
pub trim_blocks: bool,
pub lstrip_blocks: bool,
pub keep_trailing_newline: bool,
pub undefined: UndefinedBehavior,
pub pycompat: bool,
pub clock: Arc<dyn Clock>,
}
pub(crate) fn build(source: String, cfg: &EngineConfig) -> Result<Environment<'static>, Error> {
let mut env = Environment::new();
env.set_trim_blocks(cfg.trim_blocks);
env.set_lstrip_blocks(cfg.lstrip_blocks);
env.set_keep_trailing_newline(cfg.keep_trailing_newline);
env.set_undefined_behavior(cfg.undefined);
env.set_auto_escape_callback(|_name| AutoEscape::None);
let _pycompat = cfg.pycompat;
#[cfg(feature = "pycompat")]
if _pycompat {
env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
}
env.add_function("raise_exception", |msg: String| -> Result<Value, Error> {
Err(Error::new(
ErrorKind::InvalidOperation,
format!("{RAISE_SENTINEL}{msg}{RAISE_SENTINEL}"),
))
});
let clock = cfg.clock.clone();
env.add_function("strftime_now", move |fmt: String| -> Result<Value, Error> {
Ok(Value::from(clock.strftime(&fmt)))
});
env.add_filter("tojson", tojson_filter);
let source = match neutralize_generation_tags(&source) {
Cow::Borrowed(_) => source,
Cow::Owned(rewritten) => rewritten,
};
env.add_template_owned("chat", source)?;
Ok(env)
}
fn neutralize_generation_tags(source: &str) -> Cow<'_, str> {
let mut out: Option<String> = None;
let mut flushed = 0; let mut cursor = 0;
while let Some(rel) = source[cursor..].find("{%") {
let open = cursor + rel;
let Some(crel) = source[open + 2..].find("%}") else {
break; };
let close = open + 2 + crel + 2; let inner = &source[open + 2..close - 2];
let lead = inner.starts_with(['-', '+']);
let trail = inner.ends_with(['-', '+']);
let keyword = inner
.trim_start_matches(['-', '+'])
.trim_end_matches(['-', '+'])
.trim();
let replacement_kw = match keyword {
"generation" => Some("if true"),
"endgeneration" => Some("endif"),
_ => None,
};
if let Some(kw) = replacement_kw {
let buf = out.get_or_insert_with(String::new);
buf.push_str(&source[flushed..open]);
buf.push_str("{%");
if lead {
buf.push_str(&inner[..1]);
}
buf.push(' ');
buf.push_str(kw);
buf.push(' ');
if trail {
buf.push_str(&inner[inner.len() - 1..]);
}
buf.push_str("%}");
flushed = close;
}
cursor = close;
}
match out {
Some(mut buf) => {
buf.push_str(&source[flushed..]);
Cow::Owned(buf)
}
None => Cow::Borrowed(source),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_cfg() -> EngineConfig {
EngineConfig {
trim_blocks: true,
lstrip_blocks: true,
keep_trailing_newline: true,
undefined: UndefinedBehavior::Lenient,
pycompat: false,
clock: Arc::new(crate::clock::SystemClock),
}
}
#[test]
fn leaves_sources_without_the_tag_untouched() {
let src = "{% if add_generation_prompt %}<gen>{% endif %}";
assert!(matches!(neutralize_generation_tags(src), Cow::Borrowed(_)));
}
#[test]
fn rewrites_plain_and_marked_tags() {
assert_eq!(
neutralize_generation_tags("a{% generation %}b{% endgeneration %}c"),
"a{% if true %}b{% endif %}c"
);
assert_eq!(
neutralize_generation_tags("a{%- generation -%}b{%- endgeneration -%}c"),
"a{%- if true -%}b{%- endif -%}c"
);
assert_eq!(
neutralize_generation_tags("{%generation%}x{%endgeneration%}"),
"{% if true %}x{% endif %}"
);
}
#[test]
fn rewritten_template_renders_body_byte_identically() {
let with_gen = "x\n{% generation %}\nbody\n{% endgeneration %}\ny\n".to_string();
let with_if = "x\n{% if true %}\nbody\n{% endif %}\ny\n".to_string();
let cfg = default_cfg();
let gen_env = build(with_gen, &cfg).expect("generation block compiles after rewrite");
let if_env = build(with_if, &cfg).expect("control compiles");
let ctx = Value::from(());
let gen_out = gen_env.get_template("chat").unwrap().render(&ctx).unwrap();
let if_out = if_env.get_template("chat").unwrap().render(&ctx).unwrap();
assert_eq!(gen_out, if_out);
}
}