//! The one template language the workspace renders with: Jinja, through //! `minijinja`, with an unknown variable an error rather than an empty string. //! //! A template is `{{ name }}` for a value and `{% if cond %}...{% endif %}` for //! a block; a lone `{` or `}` is plain text. Callers pass their context as any //! `Serialize` value and get back the text or a message naming what is wrong. use std::collections::BTreeSet; use minijinja::{Environment, UndefinedBehavior}; use serde::Serialize; /// An environment that refuses a name the context does not hold, keeps the /// template's trailing newline, and drops a block tag's own line (`{% if %}` on a /// line by itself leaves no blank line behind). fn environment() -> Environment<'static> { let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); env.set_keep_trailing_newline(true); env.set_trim_blocks(true); env.set_lstrip_blocks(true); env } /// `template` rendered against `context`. /// /// # Errors /// /// The template does not parse, or it names a variable `context` does not hold. pub fn render(template: &str, context: &impl Serialize) -> Result { environment() .render_str(template, context) .map_err(|e| e.to_string()) } /// The variables `template` reads that it does not define itself, such as `repo` /// for `{{ repo }}`. Lets a caller refuse a template that names an unknown field /// before any event renders it. /// /// # Errors /// /// The template does not parse. pub fn variables(template: &str) -> Result, String> { let env = environment(); let parsed = env.template_from_str(template).map_err(|e| e.to_string())?; Ok(parsed.undeclared_variables(false).into_iter().collect()) } #[cfg(test)] mod tests { use serde_json::json; use super::*; #[test] fn a_value_and_a_block_render() { let ctx = json!({ "repo": "o/r", "role": "agent" }); let out = render( "{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n", &ctx, ); assert_eq!(out.as_deref(), Ok("o/r (agent)")); } #[test] fn a_block_tag_on_its_own_line_leaves_no_line_behind() { let out = render( "a\n{% if on %}\nb\n{% else %}\nc\n{% endif %}\nd\n", &json!({ "on": false }), ); assert_eq!(out.as_deref(), Ok("a\nc\nd\n")); } #[test] fn single_braces_are_plain_text() { assert_eq!( render("{not a name} and { open", &json!({})).as_deref(), Ok("{not a name} and { open") ); } #[test] fn an_unknown_variable_is_an_error() { assert!(render("{{ numbr }}", &json!({ "number": "7" })).is_err()); } #[test] fn variables_lists_what_the_template_reads_not_what_it_defines() { let found = variables("{{ repo }}#{{ number }}{% set x = 1 %}{{ x }}").expect("parses"); assert_eq!( found.into_iter().collect::>(), ["number", "repo"].map(String::from) ); } #[test] fn a_template_that_does_not_parse_is_refused() { assert!(variables("{{ repo ").is_err()); assert!(render("{% if %}", &json!({})).is_err()); } }