use std::collections::HashMap; use std::fs::File; use std::io::Read; use std::io::{Error, Result}; use std::path::Path; const TXT_TEMPLATE: &str = include_str!("../templates/index.txt"); const HTML_TEMPLATE: &str = include_str!("../templates/index.html"); const GMI_TEMPLATE: &str = include_str!("../templates/index.gmi"); const GPH_TEMPLATE: &str = include_str!("../templates/index.gph"); const RSS_TEMPLATE: &str = include_str!("../templates/feed.xml"); // Parse and Render #[derive(Debug, PartialEq)] pub enum Token { Text(String), DisplayDirective { content: String, }, ConditionalDirective { condition: String, children: Vec, }, IteratorDirective { collection: String, member_label: String, children: Vec, }, } impl std::fmt::Display for Token { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Token::Text(label) => write!(f, "Text {label}"), Token::DisplayDirective { content } => write!(f, "DisplayDirective {content}"), Token::ConditionalDirective { condition, children, } => { writeln!(f, "ConditionalDirective {condition} [[[")?; for child in children { writeln!(f, "\t{child}")?; } write!(f, "]]]") } Token::IteratorDirective { collection, member_label, children, } => { writeln!(f, "IteratorDirective {member_label} in {collection} [[[")?; for child in children { writeln!(f, "\t{child}")?; } write!(f, "]]]") } } } } #[derive(PartialEq, Debug, Clone)] pub enum Value { String(String), Unsigned(u64), Bool(bool), Collection(Vec), Context(Context), } impl Value { fn render(&self) -> String { match self { Value::String(string) => string.clone(), Value::Unsigned(number) => format!("{number}"), Value::Bool(bool) => format!("{bool}"), _ => String::new(), } } } pub type Context = HashMap; struct ContextGetter {} impl ContextGetter { fn get(context: &Context, path: &str) -> Option { let path_parts: Vec<&str> = path.split('.').collect(); ContextGetter::recursively_get_value(context, &path_parts) } fn recursively_get_value(context: &Context, path: &[&str]) -> Option { let next_path = path.first()?.to_owned(); match context.get(next_path) { Some(Value::Context(next)) if path.len() > 1 => { let remainder = &path.get(1..)?.to_owned(); ContextGetter::recursively_get_value(next, remainder) } Some(value) if path.len() == 1 => Some(value.clone()), _ => None, } } } #[derive(Debug)] pub struct Parsed { pub tokens: Vec, } impl Parsed { pub fn render(&self, context: &Context) -> Result { Parsed::render_tokens(&self.tokens, context) } pub fn render_tokens(tokens: &Vec, context: &Context) -> Result { let mut rendered_template: String = String::new(); for token in tokens { match token { Token::Text(contents) => rendered_template.push_str(contents), Token::DisplayDirective { content } => { let value = ContextGetter::get(context, content) .ok_or_else(|| Error::other(format!("{content} is not a valid key")))?; rendered_template.push_str(&value.render()); } Token::ConditionalDirective { condition, children, } => { let mut negator = false; let mut condition = condition.clone(); if condition.starts_with('!') { negator = true; condition = condition[1..].to_string(); } let value = ContextGetter::get(context, &condition) .ok_or_else(|| Error::other(format!("{condition} is not a valid key")))?; match value { Value::Bool(value) => { if negator ^ value { rendered_template .push_str(&Parsed::render_tokens(children, context)?); } Ok(()) } _ => Err(Error::other(format!("{condition} is not a boolean value"))), }?; } Token::IteratorDirective { collection, member_label, children, } => { let value = ContextGetter::get(context, collection) .ok_or_else(|| Error::other(format!("{collection} is not a valid key")))?; match value { Value::Collection(collection) => { for member in collection { let mut child_context = context.clone(); child_context .insert(member_label.clone(), Value::Context(member)); rendered_template .push_str(&Parsed::render_tokens(children, &child_context)?); } Ok(()) } _ => Err(Error::other(format!("{collection} is not a collection"))), }?; } } } Ok(rendered_template) } } pub fn parse(template: &str) -> Option { let mut tokens = Vec::new(); tokenize(template, &mut tokens).ok()?; Some(Parsed { tokens }) } fn tokenize(template: &str, tokens: &mut Vec) -> Result<()> { let mut remaining_template = template; while !remaining_template.is_empty() && remaining_template.contains("{{") { let directive_start_index = remaining_template .find("{{") .ok_or_else(|| Error::other("Was expecting at least one tag opener"))?; if directive_start_index > 0 { let text = remaining_template[..directive_start_index].to_string(); tokens.push(Token::Text(text.clone())); } remaining_template = &remaining_template[directive_start_index..]; let directive_end_index = remaining_template .find("}}") .ok_or_else(|| Error::other("Was expecting }} after {{"))? + 2; let directive = &remaining_template[..directive_end_index]; remaining_template = &remaining_template[directive_end_index..]; let directive_type = directive.chars().nth(2).unwrap_or(' '); match directive_type { // Simple Directives '=' => { let content = directive[3..directive.len() - 2].trim(); tokens.push(Token::DisplayDirective { content: content.to_string(), }); } // Block Directives '?' | '~' => { let content = directive[3..directive.len() - 2].trim(); let mut children = Vec::new(); if directive_type == '?' { let closing_block = remaining_template.find("{{?}}").ok_or_else(|| { Error::other( "Could not find closing tag, expecting {{?}}, found end-of-file", ) })?; let directive_block = &remaining_template[..closing_block]; remaining_template = &remaining_template[closing_block + 5..]; tokenize(directive_block, &mut children)?; tokens.push(Token::ConditionalDirective { condition: content.to_string(), children, }); } else if directive_type == '~' { let parts: Vec<_> = content.splitn(2, ':').collect(); let closing_block = remaining_template.find("{{~}}").ok_or_else(|| { Error::other( "Could not find closing tag, expecting {{?}}, found end-of-file", ) })?; let directive_block = &remaining_template[..closing_block]; remaining_template = &remaining_template[closing_block + 5..]; tokenize(directive_block, &mut children)?; if parts.len() == 2 && let Some(first_part) = parts.first() && let Some(second_part) = parts.get(1) { let collection = first_part.trim().to_string(); let member_label = second_part.trim().to_string(); tokens.push(Token::IteratorDirective { collection, member_label, children, }); } } } _ => { return Err(Error::other( "{{ }} is not a valid directive. Need one of: =, ? or ~.", )); } } } tokens.push(Token::Text(remaining_template.to_string())); Ok(()) } // File helpers. pub fn find(template_directory: &Path, filename: &str) -> Option { let template_path = template_directory.join(filename); if template_path.exists() { let mut contents = String::new(); if File::open(template_path) .ok()? .read_to_string(&mut contents) .is_ok() { return Some(contents); } } find_default(filename) } fn find_default(filename: &str) -> Option { match filename { "index.txt" => Some(TXT_TEMPLATE.to_string()), "index.html" => Some(HTML_TEMPLATE.to_string()), "index.gmi" => Some(GMI_TEMPLATE.to_string()), "index.gph" => Some(GPH_TEMPLATE.to_string()), "feed.xml" => Some(RSS_TEMPLATE.to_string()), &_ => None, } } #[cfg(test)] mod tests { use std::path::PathBuf; use super::*; use test_utilities::*; // Template Parser #[test] fn test_empty_string_can_be_parsed() { let template = ""; if let Some(template) = parse(template) { assert_eq!(template.tokens.len(), 1); assert_eq!(template.tokens.first(), Some(&Token::Text(String::new()))); } else { panic!("Expected empty template to be parsed"); } } #[test] fn fails_on_malformed_directive() { let template = "{{ Left door open"; let parsed_template = parse(template); assert!(parsed_template.is_none()); } #[test] fn fails_on_malformed_conditional() { let template = "{{? hello }} NO TERMINATOR!!"; let parsed_template = parse(template); assert!(parsed_template.is_none()); } #[test] fn fails_on_malformed_iterator() { let template = "{{~ hello }} NO TERMINATOR!!"; let parsed_template = parse(template); assert!(parsed_template.is_none()); } #[test] fn fails_on_malformed_conditional_body() { let template = "{{? hello }} {{ someone forgot {{?}}"; let parsed_template = parse(template); assert!(parsed_template.is_none()); } #[test] fn fails_on_malformed_iterator_body() { let template = "{{~ hello }} {{ my right hand claw {{~}}"; let parsed_template = parse(template); assert!(parsed_template.is_none()); } #[test] fn test_fails_on_invalid_directives() { let template = "My name is {{ name }}"; let template = parse(template); assert!(template.is_none()); } #[test] fn test_recognizes_display_directive() { let template = "My name is: {{= blog }}"; if let Some(template) = parse(template) { assert_eq!(template.tokens.len(), 3); assert_eq!( template.tokens.first(), Some(&Token::Text("My name is: ".to_string())) ); assert_eq!( template.tokens.get(1), Some(&Token::DisplayDirective { content: "blog".to_string() }) ); assert_eq!(template.tokens.get(2), Some(&Token::Text(String::new()))); } else { panic!("Expected empty template to be parsed"); } } #[test] fn test_not_allowed_to_nest_directives_in_display() { let template = "{{= {{ hello }} }}"; if let Some(template) = parse(template) { assert_eq!(template.tokens.len(), 2); assert_eq!( template.tokens.first(), Some(&Token::DisplayDirective { content: "{{ hello".to_string() }) ); assert_eq!( template.tokens.get(1), Some(&Token::Text(" }}".to_string())) ); } else { panic!("Expected empty template to be parsed"); } } #[test] fn test_recognizes_conditional_directive() { let template = "{{? what }}OK {{= hello }}{{?}}"; if let Some(template) = parse(template) { assert_eq!(template.tokens.len(), 2); assert_eq!( template.tokens.first(), Some(&Token::ConditionalDirective { condition: "what".to_string(), children: vec![ Token::Text("OK ".to_string()), Token::DisplayDirective { content: "hello".to_string() }, Token::Text(String::new()), ] }) ); assert_eq!(template.tokens.get(1), Some(&Token::Text(String::new()))); } else { panic!("Expected empty template to be parsed"); } } #[test] fn test_recognizes_iterator_directive() { let template = "{{~ murder:crow }}Sound: {{= caw }}{{~}}"; if let Some(template) = parse(template) { assert_eq!(template.tokens.len(), 2); assert_eq!( template.tokens.first(), Some(&Token::IteratorDirective { collection: "murder".to_string(), member_label: "crow".to_string(), children: vec![ Token::Text("Sound: ".to_string()), Token::DisplayDirective { content: "caw".to_string() }, Token::Text(String::new()), ] }) ); assert_eq!(template.tokens.get(1), Some(&Token::Text(String::new()))); } else { panic!("Expected empty template to be parsed"); } } // Template Debug Formatting #[test] fn test_displays_text_token_in_readable_format() { let token = Token::Text("Bugs are cool".to_string()); assert_eq!(format!("{token}"), "Text Bugs are cool"); } #[test] fn test_displays_display_directive_token_in_readable_format() { let token = Token::DisplayDirective { content: "Wait, not that kind!".to_string(), }; assert_eq!(format!("{token}"), "DisplayDirective Wait, not that kind!"); } #[test] fn test_displays_conditional_directive_token_in_readable_format() { let token = Token::ConditionalDirective { condition: "Woah".to_string(), children: vec![Token::Text("The kind with six to eight legs".to_string())], }; assert_eq!( format!("{token}"), "ConditionalDirective Woah [[[ \tText The kind with six to eight legs ]]]" ); } #[test] fn test_displays_iterator_directive_token_in_readable_format() { let token = Token::IteratorDirective { collection: "bugs".to_string(), member_label: "bug".to_string(), children: vec![Token::Text("yeah, that's right!".to_string())], }; assert_eq!( format!("{token}"), "IteratorDirective bug in bugs [[[ \tText yeah, that's right! ]]]" ); } // Template Rendering #[test] fn test_renders_a_parsed_template() { let template = "\ # This is my template. My name is {{= name }} {{? positive }}Big if true: {{= size }}.{{?}} {{? !positive }}False, {{= name }}.{{?}} {{~ bits:bit }}Bit: {{= bit.z }}.{{~}} The truth is {{= positive }} But this is not: {{= bits }}. One last {{= last.one }} "; let context: Context = HashMap::from([ ("name".to_string(), Value::String("Aceituno".to_string())), ( "last".to_string(), Value::Context(HashMap::from([( "one".to_string(), Value::String("OK".to_string()), )])), ), ("positive".to_string(), Value::Bool(true)), ("size".to_string(), Value::Unsigned(28)), ( "bits".to_string(), Value::Collection(vec![ HashMap::from([("z".to_string(), Value::String("Z".to_string()))]), HashMap::from([("z".to_string(), Value::String("Zz".to_string()))]), HashMap::from([("z".to_string(), Value::String("ZzZ".to_string()))]), ]), ), ]); if let Some(template) = parse(template) { match template.render(&context) { Err(error) => panic!("{error}"), Ok(output) => assert_eq!( output, "\ # This is my template. My name is Aceituno Big if true: 28. Bit: Z.Bit: Zz.Bit: ZzZ. The truth is true But this is not: . One last OK " ), } } else { panic!("Expected template to be parsed"); } } #[test] fn test_rendering_fails_on_incorrect_keys() { let template = "My name is {{= name }}"; let context: Context = HashMap::new(); if let Some(template) = parse(template) { let output = template.render(&context); assert!(output.is_err()); } else { panic!("Expected template to be parsed"); } } #[test] fn test_rendering_fails_on_incorrect_conditions() { let template = "{{? what_is_to_be_done }}not_this{{?}}"; let context: Context = HashMap::new(); if let Some(template) = parse(template) { let output = template.render(&context); assert!(output.is_err()); } else { panic!("Expected template to be parsed"); } } #[test] fn test_rendering_fails_on_non_boolean_conditions() { let template = "{{? question }}answer{{?}}"; let context: Context = HashMap::from([( "question".to_string(), Value::String("more a comment than a question".to_string()), )]); if let Some(template) = parse(template) { let output = template.render(&context); assert!(output.is_err()); } else { panic!("Expected template to be parsed"); } } #[test] fn test_rendering_fails_on_incorrect_collections() { let template = "{{~ nothings:sweet }}ugh{{~}}"; let context: Context = HashMap::new(); if let Some(template) = parse(template) { let output = template.render(&context); assert!(output.is_err()); } else { panic!("Expected template to be parsed"); } } #[test] fn test_rendering_fails_on_incorrect_iterator_children() { let template = "{{~ somethings:sour }}{{= sour.nok }}{{~}}"; let context: Context = HashMap::from([( "somethings".to_string(), Value::Collection(vec![HashMap::from([( "ok".to_string(), Value::String("Very much".to_string()), )])]), )]); if let Some(template) = parse(template) { let output = template.render(&context); assert!(output.is_err()); } else { panic!("Expected template to be parsed"); } } #[test] fn test_rendering_fails_on_non_collection_collections() { let template = "{{~ sheeps:sheep }}{{= sheep.bah }}{{~}}"; let context: Context = HashMap::from([("sheeps".to_string(), Value::String("wolf".to_string()))]); if let Some(template) = parse(template) { let output = template.render(&context); assert!(output.is_err()); } else { panic!("Expected template to be parsed"); } } // Template Finder #[test] fn test_loads_template_from_path_if_exists() { let test_dir = setup_test_dir(); create_test_file(&test_dir.join("index.html"), "Hello"); if let Some(template) = find(&test_dir, "index.html") { assert_eq!(template, "Hello"); } else { panic!("Could not find provided index."); } cleanup_test_dir(&test_dir); } #[test] fn test_there_is_no_default_for_unknown_types() { let template = find( &PathBuf::from("/norealpath/ifthisfails/then/wow"), "image.png", ); assert!(template.is_none()); } #[test] fn test_finds_default_html_templalte() { let template = find( &PathBuf::from("/norealpath/ifthisfails/then/wow"), "index.html", ); assert!(template.is_some()); } #[test] fn test_finds_default_txt_templalte() { let template = find( &PathBuf::from("/norealpath/ifthisfails/then/wow"), "index.txt", ); assert!(template.is_some()); } #[test] fn test_finds_default_gmi_templalte() { let template = find( &PathBuf::from("/norealpath/ifthisfails/then/wow"), "index.gmi", ); assert!(template.is_some()); } #[test] fn test_finds_default_rss_templalte() { let template = find( &PathBuf::from("/norealpath/ifthisfails/then/wow"), "feed.xml", ); assert!(template.is_some()); } }