diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/template.rs | 452 |
1 files changed, 444 insertions, 8 deletions
diff --git a/src/template.rs b/src/template.rs index 33bc662..53639d9 100644 --- a/src/template.rs +++ b/src/template.rs @@ -11,6 +11,7 @@ const RSS_TEMPLATE: &str = include_str!("../templates/feed.xml"); // Parse and Render +#[derive(Debug, PartialEq)] pub enum Token { Text(String), DisplayDirective { @@ -38,20 +39,20 @@ impl std::fmt::Display for Token { } => { writeln!(f, "ConditionalDirective {condition} [[[")?; for child in children { - writeln!(f, "\t{child}")?; + writeln!(f, "\t{child}").unwrap(); } - write!(f, "\n]]]") + write!(f, "]]]") } Token::IteratorDirective { collection, member_label, children, } => { - writeln!(f, "{collection} in {member_label}")?; + writeln!(f, "IteratorDirective {member_label} in {collection} [[[")?; for child in children { - writeln!(f, "\t{child}")?; + writeln!(f, "\t{child}").unwrap(); } - write!(f, "\n]]]") + write!(f, "]]]") } } } @@ -97,6 +98,7 @@ impl ContextGetter { } } +#[derive(Debug)] pub struct Parsed { pub tokens: Vec<Token>, } @@ -220,7 +222,12 @@ fn tokenize(template: &str, tokens: &mut Vec<Token>) -> Result<()> { match directive_type { '?' => { - let closing_block = remaining_template.find("{{?}}").unwrap(); + let closing_block = remaining_template.find("{{?}}").ok_or_else(|| { + Error::new( + 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)?; @@ -231,7 +238,12 @@ fn tokenize(template: &str, tokens: &mut Vec<Token>) -> Result<()> { } '~' => { let parts: Vec<_> = content.splitn(2, ':').collect(); - let closing_block = remaining_template.find("{{~}}").unwrap(); + let closing_block = remaining_template.find("{{~}}").ok_or_else(|| { + Error::new( + 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)?; @@ -246,7 +258,12 @@ fn tokenize(template: &str, tokens: &mut Vec<Token>) -> Result<()> { _ => unreachable!(), } } - _ => unreachable!(), + _ => { + return Err(Error::new( + Other, + "{{ }} is not a valid directive. Need one of: =, ? or ~.", + )) + } } } tokens.push(Token::Text(remaining_template.to_string())); @@ -279,3 +296,422 @@ fn find_default(filename: &str) -> Option<String> { &_ => None, } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use test_utilities::*; + + use super::*; + + // 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[0], Token::Text("".to_string())); + } 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 context: Context = + HashMap::from([("name".to_string(), Value::String("Scrooge".to_string()))]); + + 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[0], Token::Text("My name is: ".to_string())); + assert_eq!( + template.tokens[1], + Token::DisplayDirective { + content: "blog".to_string() + } + ); + assert_eq!(template.tokens[2], Token::Text("".to_string())); + } 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[0], + Token::DisplayDirective { + content: "{{ hello".to_string() + } + ); + assert_eq!(template.tokens[1], 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[0], + Token::ConditionalDirective { + condition: "what".to_string(), + children: vec![ + Token::Text("OK ".to_string()), + Token::DisplayDirective { + content: "hello".to_string() + }, + Token::Text("".to_string()), + ] + } + ); + assert_eq!(template.tokens[1], Token::Text("".to_string())); + } 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[0], + 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("".to_string()), + ] + } + ); + assert_eq!(template.tokens[1], Token::Text("".to_string())); + } 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_none()); + } + + #[test] + fn test_finds_default_txt_templalte() { + let template = find( + &PathBuf::from("/norealpath/ifthisfails/then/wow"), + "index.txt", + ); + assert!(!template.is_none()); + } + + #[test] + fn test_finds_default_gmi_templalte() { + let template = find( + &PathBuf::from("/norealpath/ifthisfails/then/wow"), + "index.gmi", + ); + assert!(!template.is_none()); + } + + #[test] + fn test_finds_default_rss_templalte() { + let template = find( + &PathBuf::from("/norealpath/ifthisfails/then/wow"), + "index.rss", + ); + assert!(!template.is_none()); + } +} |