use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use std::{env, fs}; #[cfg(feature = "serde")] use serde::Deserialize; use crate::parser::{Syntax, Whitespace}; use crate::CompileError; #[derive(Debug)] pub(crate) struct Config<'a> { pub(crate) dirs: Vec, pub(crate) syntaxes: BTreeMap>, pub(crate) default_syntax: &'a str, pub(crate) escapers: Vec<(HashSet, String)>, pub(crate) whitespace: WhitespaceHandling, } impl<'a> Config<'a> { pub(crate) fn new( s: &'a str, template_whitespace: Option<&String>, ) -> std::result::Result, CompileError> { let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let default_dirs = vec![root.join("templates")]; let mut syntaxes = BTreeMap::new(); syntaxes.insert(DEFAULT_SYNTAX_NAME.to_string(), Syntax::default()); let raw = if s.is_empty() { RawConfig::default() } else { RawConfig::from_toml_str(s)? }; let (dirs, default_syntax, mut whitespace) = match raw.general { Some(General { dirs, default_syntax, whitespace, }) => ( dirs.map_or(default_dirs, |v| { v.into_iter().map(|dir| root.join(dir)).collect() }), default_syntax.unwrap_or(DEFAULT_SYNTAX_NAME), whitespace, ), None => ( default_dirs, DEFAULT_SYNTAX_NAME, WhitespaceHandling::default(), ), }; if let Some(template_whitespace) = template_whitespace { whitespace = match template_whitespace.as_str() { "suppress" => WhitespaceHandling::Suppress, "minimize" => WhitespaceHandling::Minimize, "preserve" => WhitespaceHandling::Preserve, s => return Err(format!("invalid value for `whitespace`: \"{s}\"").into()), }; } if let Some(raw_syntaxes) = raw.syntax { for raw_s in raw_syntaxes { let name = raw_s.name; if syntaxes .insert(name.to_string(), raw_s.try_into()?) .is_some() { return Err(format!("syntax \"{name}\" is already defined").into()); } } } if !syntaxes.contains_key(default_syntax) { return Err(format!("default syntax \"{default_syntax}\" not found").into()); } let mut escapers = Vec::new(); if let Some(configured) = raw.escaper { for escaper in configured { escapers.push(( escaper .extensions .iter() .map(|ext| (*ext).to_string()) .collect(), escaper.path.to_string(), )); } } for (extensions, path) in DEFAULT_ESCAPERS { escapers.push((str_set(extensions), (*path).to_string())); } Ok(Config { dirs, syntaxes, default_syntax, escapers, whitespace, }) } pub(crate) fn find_template( &self, path: &str, start_at: Option<&Path>, ) -> std::result::Result { if let Some(root) = start_at { let relative = root.with_file_name(path); if relative.exists() { return Ok(relative); } } for dir in &self.dirs { let rooted = dir.join(path); if rooted.exists() { return Ok(rooted); } } Err(format!( "template {:?} not found in directories {:?}", path, self.dirs ) .into()) } } impl<'a> TryInto> for RawSyntax<'a> { type Error = CompileError; fn try_into(self) -> Result, Self::Error> { let default = Syntax::default(); let syntax = Syntax { block_start: self.block_start.unwrap_or(default.block_start), block_end: self.block_end.unwrap_or(default.block_end), expr_start: self.expr_start.unwrap_or(default.expr_start), expr_end: self.expr_end.unwrap_or(default.expr_end), comment_start: self.comment_start.unwrap_or(default.comment_start), comment_end: self.comment_end.unwrap_or(default.comment_end), }; for s in [ syntax.block_start, syntax.block_end, syntax.expr_start, syntax.expr_end, syntax.comment_start, syntax.comment_end, ] { if s.len() < 2 { return Err( format!("delimiters must be at least two characters long: {s:?}").into(), ); } else if s.chars().any(|c| c.is_whitespace()) { return Err(format!("delimiters may not contain white spaces: {s:?}").into()); } } for (s1, s2) in [ (syntax.block_start, syntax.expr_start), (syntax.block_start, syntax.comment_start), (syntax.expr_start, syntax.comment_start), ] { if s1.starts_with(s2) || s2.starts_with(s1) { return Err(format!( "a delimiter may not be the prefix of another delimiter: {s1:?} vs {s2:?}", ) .into()); } } Ok(syntax) } } #[cfg_attr(feature = "serde", derive(Deserialize))] #[derive(Default)] struct RawConfig<'a> { #[cfg_attr(feature = "serde", serde(borrow))] general: Option>, syntax: Option>>, escaper: Option>>, } impl RawConfig<'_> { #[cfg(feature = "config")] fn from_toml_str(s: &str) -> std::result::Result, CompileError> { basic_toml::from_str(s) .map_err(|e| format!("invalid TOML in {CONFIG_FILE_NAME}: {e}").into()) } #[cfg(not(feature = "config"))] fn from_toml_str(_: &str) -> std::result::Result, CompileError> { Err("TOML support not available".into()) } } #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "serde", derive(Deserialize))] #[cfg_attr(feature = "serde", serde(field_identifier, rename_all = "lowercase"))] pub(crate) enum WhitespaceHandling { /// The default behaviour. It will leave the whitespace characters "as is". Preserve, /// It'll remove all the whitespace characters before and after the jinja block. Suppress, /// It'll remove all the whitespace characters except one before and after the jinja blocks. /// If there is a newline character, the preserved character in the trimmed characters, it will /// the one preserved. Minimize, } impl From for Whitespace { fn from(ws: WhitespaceHandling) -> Self { match ws { WhitespaceHandling::Suppress => Whitespace::Suppress, WhitespaceHandling::Preserve => Whitespace::Preserve, WhitespaceHandling::Minimize => Whitespace::Minimize, } } } impl Default for WhitespaceHandling { fn default() -> Self { WhitespaceHandling::Preserve } } #[cfg_attr(feature = "serde", derive(Deserialize))] struct General<'a> { #[cfg_attr(feature = "serde", serde(borrow))] dirs: Option>, default_syntax: Option<&'a str>, #[cfg_attr(feature = "serde", serde(default))] whitespace: WhitespaceHandling, } #[cfg_attr(feature = "serde", derive(Deserialize))] struct RawSyntax<'a> { name: &'a str, block_start: Option<&'a str>, block_end: Option<&'a str>, expr_start: Option<&'a str>, expr_end: Option<&'a str>, comment_start: Option<&'a str>, comment_end: Option<&'a str>, } #[cfg_attr(feature = "serde", derive(Deserialize))] struct RawEscaper<'a> { path: &'a str, extensions: Vec<&'a str>, } pub(crate) fn read_config_file( config_path: Option<&str>, ) -> std::result::Result { let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let filename = match config_path { Some(config_path) => root.join(config_path), None => root.join(CONFIG_FILE_NAME), }; if filename.exists() { fs::read_to_string(&filename) .map_err(|_| format!("unable to read {:?}", filename.to_str().unwrap()).into()) } else if config_path.is_some() { Err(format!("`{}` does not exist", root.display()).into()) } else { Ok("".to_string()) } } fn str_set(vals: &[T]) -> HashSet where T: ToString, { vals.iter().map(|s| s.to_string()).collect() } #[allow(clippy::match_wild_err_arm)] pub(crate) fn get_template_source(tpl_path: &Path) -> std::result::Result { match fs::read_to_string(tpl_path) { Err(_) => Err(format!( "unable to open template file '{}'", tpl_path.to_str().unwrap() ) .into()), Ok(mut source) => { if source.ends_with('\n') { let _ = source.pop(); } Ok(source) } } } static CONFIG_FILE_NAME: &str = "askama.toml"; static DEFAULT_SYNTAX_NAME: &str = "default"; static DEFAULT_ESCAPERS: &[(&[&str], &str)] = &[ (&["html", "htm", "xml"], "::askama::Html"), (&["md", "none", "txt", "yml", ""], "::askama::Text"), (&["j2", "jinja", "jinja2"], "::askama::Html"), ]; #[cfg(test)] mod tests { use std::env; use std::path::{Path, PathBuf}; use super::*; #[test] fn get_source() { let path = Config::new("", None) .and_then(|config| config.find_template("b.html", None)) .unwrap(); assert_eq!(get_template_source(&path).unwrap(), "bar"); } #[test] fn test_default_config() { let mut root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); root.push("templates"); let config = Config::new("", None).unwrap(); assert_eq!(config.dirs, vec![root]); } #[cfg(feature = "config")] #[test] fn test_config_dirs() { let mut root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); root.push("tpl"); let config = Config::new("[general]\ndirs = [\"tpl\"]", None).unwrap(); assert_eq!(config.dirs, vec![root]); } fn assert_eq_rooted(actual: &Path, expected: &str) { let mut root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); root.push("templates"); let mut inner = PathBuf::new(); inner.push(expected); assert_eq!(actual.strip_prefix(root).unwrap(), inner); } #[test] fn find_absolute() { let config = Config::new("", None).unwrap(); let root = config.find_template("a.html", None).unwrap(); let path = config.find_template("sub/b.html", Some(&root)).unwrap(); assert_eq_rooted(&path, "sub/b.html"); } #[test] #[should_panic] fn find_relative_nonexistent() { let config = Config::new("", None).unwrap(); let root = config.find_template("a.html", None).unwrap(); config.find_template("c.html", Some(&root)).unwrap(); } #[test] fn find_relative() { let config = Config::new("", None).unwrap(); let root = config.find_template("sub/b.html", None).unwrap(); let path = config.find_template("c.html", Some(&root)).unwrap(); assert_eq_rooted(&path, "sub/c.html"); } #[test] fn find_relative_sub() { let config = Config::new("", None).unwrap(); let root = config.find_template("sub/b.html", None).unwrap(); let path = config.find_template("sub1/d.html", Some(&root)).unwrap(); assert_eq_rooted(&path, "sub/sub1/d.html"); } #[cfg(feature = "config")] #[test] fn add_syntax() { let raw_config = r#" [general] default_syntax = "foo" [[syntax]] name = "foo" block_start = "{<" [[syntax]] name = "bar" expr_start = "{!" "#; let default_syntax = Syntax::default(); let config = Config::new(raw_config, None).unwrap(); assert_eq!(config.default_syntax, "foo"); let foo = config.syntaxes.get("foo").unwrap(); assert_eq!(foo.block_start, "{<"); assert_eq!(foo.block_end, default_syntax.block_end); assert_eq!(foo.expr_start, default_syntax.expr_start); assert_eq!(foo.expr_end, default_syntax.expr_end); assert_eq!(foo.comment_start, default_syntax.comment_start); assert_eq!(foo.comment_end, default_syntax.comment_end); let bar = config.syntaxes.get("bar").unwrap(); assert_eq!(bar.block_start, default_syntax.block_start); assert_eq!(bar.block_end, default_syntax.block_end); assert_eq!(bar.expr_start, "{!"); assert_eq!(bar.expr_end, default_syntax.expr_end); assert_eq!(bar.comment_start, default_syntax.comment_start); assert_eq!(bar.comment_end, default_syntax.comment_end); } #[cfg(feature = "config")] #[test] fn add_syntax_two() { let raw_config = r#" syntax = [{ name = "foo", block_start = "{<" }, { name = "bar", expr_start = "{!" } ] [general] default_syntax = "foo" "#; let default_syntax = Syntax::default(); let config = Config::new(raw_config, None).unwrap(); assert_eq!(config.default_syntax, "foo"); let foo = config.syntaxes.get("foo").unwrap(); assert_eq!(foo.block_start, "{<"); assert_eq!(foo.block_end, default_syntax.block_end); assert_eq!(foo.expr_start, default_syntax.expr_start); assert_eq!(foo.expr_end, default_syntax.expr_end); assert_eq!(foo.comment_start, default_syntax.comment_start); assert_eq!(foo.comment_end, default_syntax.comment_end); let bar = config.syntaxes.get("bar").unwrap(); assert_eq!(bar.block_start, default_syntax.block_start); assert_eq!(bar.block_end, default_syntax.block_end); assert_eq!(bar.expr_start, "{!"); assert_eq!(bar.expr_end, default_syntax.expr_end); assert_eq!(bar.comment_start, default_syntax.comment_start); assert_eq!(bar.comment_end, default_syntax.comment_end); } #[cfg(feature = "config")] #[test] fn longer_delimiters() { let raw_config = r#" [[syntax]] name = "emoji" block_start = "👉🙂👉" block_end = "👈🙃👈" expr_start = "🤜🤜" expr_end = "🤛🤛" comment_start = "👎_(ツ)_👎" comment_end = "👍:D👍" [general] default_syntax = "emoji" "#; let config = Config::new(raw_config, None).unwrap(); assert_eq!(config.default_syntax, "emoji"); let foo = config.syntaxes.get("emoji").unwrap(); assert_eq!(foo.block_start, "👉🙂👉"); assert_eq!(foo.block_end, "👈🙃👈"); assert_eq!(foo.expr_start, "🤜🤜"); assert_eq!(foo.expr_end, "🤛🤛"); assert_eq!(foo.comment_start, "👎_(ツ)_👎"); assert_eq!(foo.comment_end, "👍:D👍"); } #[cfg(feature = "config")] #[test] fn illegal_delimiters() { let raw_config = r#" [[syntax]] name = "too_short" block_start = "<" "#; let config = Config::new(raw_config, None); assert_eq!( config.unwrap_err().msg, r#"delimiters must be at least two characters long: "<""#, ); let raw_config = r#" [[syntax]] name = "contains_ws" block_start = " {{ " "#; let config = Config::new(raw_config, None); assert_eq!( config.unwrap_err().msg, r#"delimiters may not contain white spaces: " {{ ""#, ); let raw_config = r#" [[syntax]] name = "is_prefix" block_start = "{{" expr_start = "{{$" comment_start = "{{#" "#; let config = Config::new(raw_config, None); assert_eq!( config.unwrap_err().msg, r#"a delimiter may not be the prefix of another delimiter: "{{" vs "{{$""#, ); } #[cfg(feature = "toml")] #[should_panic] #[test] fn use_default_at_syntax_name() { let raw_config = r#" syntax = [{ name = "default" }] "#; let _config = Config::new(raw_config, None).unwrap(); } #[cfg(feature = "toml")] #[should_panic] #[test] fn duplicated_syntax_name_on_list() { let raw_config = r#" syntax = [{ name = "foo", block_start = "~<" }, { name = "foo", block_start = "%%" } ] "#; let _config = Config::new(raw_config, None).unwrap(); } #[cfg(feature = "toml")] #[should_panic] #[test] fn is_not_exist_default_syntax() { let raw_config = r#" [general] default_syntax = "foo" "#; let _config = Config::new(raw_config, None).unwrap(); } #[cfg(feature = "config")] #[test] fn escape_modes() { let config = Config::new( r#" [[escaper]] path = "::askama::Js" extensions = ["js"] "#, None, ) .unwrap(); assert_eq!( config.escapers, vec![ (str_set(&["js"]), "::askama::Js".into()), (str_set(&["html", "htm", "xml"]), "::askama::Html".into()), ( str_set(&["md", "none", "txt", "yml", ""]), "::askama::Text".into() ), (str_set(&["j2", "jinja", "jinja2"]), "::askama::Html".into()), ] ); } #[cfg(feature = "config")] #[test] fn test_whitespace_parsing() { let config = Config::new( r#" [general] whitespace = "suppress" "#, None, ) .unwrap(); assert_eq!(config.whitespace, WhitespaceHandling::Suppress); let config = Config::new(r#""#, None).unwrap(); assert_eq!(config.whitespace, WhitespaceHandling::Preserve); let config = Config::new( r#" [general] whitespace = "preserve" "#, None, ) .unwrap(); assert_eq!(config.whitespace, WhitespaceHandling::Preserve); let config = Config::new( r#" [general] whitespace = "minimize" "#, None, ) .unwrap(); assert_eq!(config.whitespace, WhitespaceHandling::Minimize); } #[cfg(feature = "toml")] #[test] fn test_whitespace_in_template() { // Checking that template arguments have precedence over general configuration. // So in here, in the template arguments, there is `whitespace = "minimize"` so // the `WhitespaceHandling` should be `Minimize` as well. let config = Config::new( r#" [general] whitespace = "suppress" "#, Some(&"minimize".to_owned()), ) .unwrap(); assert_eq!(config.whitespace, WhitespaceHandling::Minimize); let config = Config::new(r#""#, Some(&"minimize".to_owned())).unwrap(); assert_eq!(config.whitespace, WhitespaceHandling::Minimize); } #[test] fn test_config_whitespace_error() { let config = Config::new(r#""#, Some(&"trim".to_owned())); if let Err(err) = config { assert_eq!(err.msg, "invalid value for `whitespace`: \"trim\""); } else { panic!("Config::new should have return an error"); } } }