diff --git a/src/lib.rs b/src/lib.rs index c2b7e2a..0cd59a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,10 @@ use mdbook::errors::Result; use mdbook::preprocess::{Preprocessor, PreprocessorContext}; use mdbook::BookItem; +use crate::utils::{FileReader, SystemFileReader}; + mod links; +mod utils; const MAX_LINK_NESTED_DEPTH: usize = 10; @@ -36,7 +39,13 @@ impl Preprocessor for Template { .map(|dir| src_dir.join(dir)) .expect("All book items have a parent"); - let content = replace_template(&chapter.content, base, source, 0); + let content = replace_template( + &chapter.content, + &SystemFileReader::default(), + base, + source, + 0, + ); chapter.content = content; } } @@ -50,10 +59,17 @@ impl Preprocessor for Template { } } -fn replace_template(chapter_content: &str, base: P1, source: P2, depth: usize) -> String +fn replace_template( + chapter_content: &str, + file_reader: &FR, + base: P1, + source: P2, + depth: usize, +) -> String where P1: AsRef, P2: AsRef, + FR: FileReader, { let path = base.as_ref(); let source = source.as_ref(); @@ -64,12 +80,13 @@ where for link in links::extract_template_links(chapter_content) { replaced.push_str(&chapter_content[previous_end_index..link.start_index]); - match link.replace_args(&path) { + match link.replace_args(&path, file_reader) { Ok(new_content) => { if depth < MAX_LINK_NESTED_DEPTH { if let Some(rel_path) = link.link_type.relative_path(path) { replaced.push_str(&replace_template( &new_content, + file_reader, rel_path, source, depth + 1, @@ -99,4 +116,173 @@ where replaced.push_str(&chapter_content[previous_end_index..]); replaced +} + +#[cfg(test)] +mod lib_tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use crate::replace_template; + use crate::utils::TestFileReader; + + #[test] + fn test_happy_path_escaped() { + let start = r" + Example Text + ```hbs + \{{#template template.md}} << an escaped link! + ```"; + let end = r" + Example Text + ```hbs + {{#template template.md}} << an escaped link! + ```"; + + assert_eq!( + replace_template(start, &TestFileReader::default(), "", "", 0), + end + ); + } + + #[test] + fn test_happy_path_simple() { + let start_chapter_content = "{{#template footer.md}}"; + let end_chapter_content = "Designed & Created With Love From - Goudham & Hazel"; + let file_name = PathBuf::from("footer.md"); + let template_file_contents = + "Designed & Created With Love From - Goudham & Hazel".to_string(); + let map = HashMap::from([(file_name, template_file_contents)]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } + + #[test] + fn test_happy_path_with_args() { + let start_chapter_content = "{{#template footer.md authors=Goudham & Hazel}}"; + let end_chapter_content = "Designed & Created With Love From - Goudham & Hazel"; + let file_name = PathBuf::from("footer.md"); + let template_file_contents = "Designed & Created With Love From - {{$authors}}".to_string(); + let map = HashMap::from([(file_name, template_file_contents)]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } + + #[test] + fn test_happy_path_new_lines() { + let start_chapter_content = r" + Some content... + {{#template footer.md authors=Goudham & Hazel}}"; + let end_chapter_content = r" + Some content... + - - - - + Designed & Created With Love From Goudham & Hazel"; + let file_name = PathBuf::from("footer.md"); + let template_file_contents = r"- - - - + Designed & Created With Love From {{$authors}}" + .to_string(); + let map = HashMap::from([(file_name, template_file_contents)]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } + + #[test] + fn test_happy_path_multiple() { + let start_chapter_content = r" + {{#template header.md title=Example Title}} + Some content... + {{#template + footer.md + authors=Goudham & Hazel}}"; + let end_chapter_content = r" + # Example Title + Some content... + - - - - + Designed & Created With Love From Goudham & Hazel"; + let header_file_name = PathBuf::from("header.md"); + let header_contents = r"# {{$title}}".to_string(); + let footer_file_name = PathBuf::from("footer.md"); + let footer_contents = r"- - - - + Designed & Created With Love From {{$authors}}" + .to_string(); + let map = HashMap::from([ + (footer_file_name, footer_contents), + (header_file_name, header_contents), + ]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } + + #[test] + fn test_happy_path_with_default_values() { + let start_chapter_content = "{{#template footer.md}}"; + let end_chapter_content = "Designed By - Goudham"; + let file_name = PathBuf::from("footer.md"); + let template_file_contents = "Designed By - {{$authors Goudham}}".to_string(); + let map = HashMap::from([(file_name, template_file_contents)]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } + + #[test] + fn test_happy_path_with_overridden_default_values() { + let start_chapter_content = "{{#template footer.md authors=Hazel}}"; + let end_chapter_content = "Designed By - Hazel"; + let file_name = PathBuf::from("footer.md"); + let template_file_contents = "Designed By - {{$authors Goudham}}".to_string(); + let map = HashMap::from([(file_name, template_file_contents)]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } + + #[test] + fn test_happy_path_nested() { + let start_chapter_content = r" + {{#template header.md title=Example Title}} + Some content..."; + let end_chapter_content = r" + # Example Title + Example Title + Some content..."; + let header_file_name = PathBuf::from("header.md"); + let header_contents = r"# {{$title}} + {{#template image.md}}" + .to_string(); + let image_file_name = PathBuf::from("image.md"); + let image_contents = r"Example Title".to_string(); + let map = HashMap::from([ + (image_file_name, image_contents), + (header_file_name, header_contents), + ]); + let file_reader = &TestFileReader::from(map); + + let actual_chapter_content = + replace_template(start_chapter_content, file_reader, "", "", 0); + + assert_eq!(actual_chapter_content, end_chapter_content); + } } \ No newline at end of file diff --git a/src/links.rs b/src/links.rs index d780f38..996f6fe 100644 --- a/src/links.rs +++ b/src/links.rs @@ -1,12 +1,12 @@ -use std::collections::{HashMap, VecDeque}; -use std::fs; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use anyhow::Context; use fancy_regex::{CaptureMatches, Captures, Regex}; use lazy_static::lazy_static; use mdbook::errors::Result; +use crate::FileReader; + const ESCAPE_CHAR: char = '\\'; const LINE_BREAKS: &[char] = &['\n', '\r']; @@ -14,7 +14,7 @@ lazy_static! { // https://stackoverflow.com/questions/22871602/optimizing-regex-to-fine-key-value-pairs-space-delimited static ref TEMPLATE_ARGS: Regex = Regex::new(r"(?<=\s|\A)([^\s=]+)=(.*?)(?=(?:\s[^\s=]+=|$))").unwrap(); - // r"(?x)\\\{\{\#.*\}\}|\{\{\s*\#(template)\s+([a-zA-Z0-9_^'<>().:*+|\\\/?-]+)\s+([^}]+)\}\}" + // r"(?x)\\\{\{\#.*\}\}|\{\{\s*\#(template)\s+([\S]+)\s*\}\}|\{\{\s*\#(template)\s+([\S]+)\s+([^}]+)\}\}" static ref TEMPLATE: Regex = Regex::new( r"(?x) # enable insignificant whitespace mode @@ -22,12 +22,21 @@ lazy_static! { \#.* # match any character \}\} # escaped link closing parens + | # or + + \{\{\s* # link opening parens and whitespace(s) + \#(template) # link type - template + \s+ # separating whitespace + ([\S]+) # relative path to template file + \s* # optional separating whitespaces(s) + \}\} # link closing parens + | # or \{\{\s* # link opening parens and whitespace(s) \#(template) # link type - template \s+ # separating whitespace - ([\w'<>.:^\-\(\)\*\+\|\\\/\?]+) # relative path to template file + ([\S]+) # relative path to template file \s+ # separating whitespace(s) ([^}]+) # get all template arguments \}\} # link closing parens" @@ -73,67 +82,61 @@ impl<'a> Link<'a> { fn from_capture(cap: Captures<'a>) -> Option> { let mut all_args = HashMap::with_capacity(20); - let link_type = match (cap.get(0), cap.get(1), cap.get(2), cap.get(3)) { - (Some(mat), _, _, _) if mat.as_str().contains(LINE_BREAKS) => { - /* - Given a template string that looks like: - {{#template - footer.md - path=../images - author=Hazel - }} - - The resulting args: will look like: - ["{{#template", "footer.md", "path=../images", "author=Hazel", "}}"] - */ - let mut args = mat - .as_str() - .lines() - .map(|line| { - line.trim_end_matches(LINE_BREAKS) - .trim_start_matches(LINE_BREAKS) - }) - .collect::>(); - - // Remove {{#template - args.pop_front(); - // Remove ending }} - args.pop_back(); - // Store relative path of template file - let file = args.pop_front().unwrap(); - - let split_args = args - .into_iter() - .map(|arg| { - let mut split_n = arg.splitn(2, '='); - let key = split_n.next().unwrap().trim(); - let value = split_n.next().unwrap(); - (key, value) - }) - .collect::>(); - all_args.extend(split_args); - - Some(LinkType::Template(PathBuf::from(file.trim()))) - } - (_, _, Some(file), Some(args)) => { - let matches = TEMPLATE_ARGS.captures_iter(args.as_str()); - - let split_args = matches - .into_iter() - .map(|mat| { - let mut split_n = mat.unwrap().get(0).unwrap().as_str().splitn(2, '='); - let key = split_n.next().unwrap().trim(); - let value = split_n.next().unwrap(); - (key, value) - }) - .collect::>(); - all_args.extend(split_args); - + // https://regex101.com/r/OBywLv/1 + let link_type = match ( + cap.get(0), + cap.get(1), + cap.get(2), + cap.get(3), + cap.get(4), + cap.get(5), + ) { + // This looks like {{#template }} + (_, _, Some(file), None, None, None) => { Some(LinkType::Template(PathBuf::from(file.as_str()))) } - (Some(mat), _, _, _) if mat.as_str().starts_with(ESCAPE_CHAR) => { + // This looks like \{{#}} + (Some(mat), _, _, _, _, _) if mat.as_str().starts_with(ESCAPE_CHAR) => { Some(LinkType::Escaped) } + (_, None, None, _, Some(file), Some(args)) => { + let split_args = match args.as_str().contains(LINE_BREAKS) { + /* + This looks like + {{#template + + + }} + */ + true => args + .as_str() + .split(LINE_BREAKS) + .map(|str| str.trim()) + .filter(|trimmed| !trimmed.is_empty()) + .map(|mat| { + let mut split_n = mat.splitn(2, '='); + let key = split_n.next().unwrap().trim(); + let value = split_n.next().unwrap(); + (key, value) + }) + .collect::>(), + + // This looks like {{#template }} + false => TEMPLATE_ARGS + .captures_iter(args.as_str()) + .into_iter() + .map(|mat| { + let mut split_n = mat.unwrap().get(0).unwrap().as_str().splitn(2, '='); + let key = split_n.next().unwrap().trim(); + let value = split_n.next().unwrap(); + (key, value) + }) + .collect::>(), + }; + + all_args.extend(split_args); + Some(LinkType::Template(PathBuf::from(file.as_str()))) + } _ => None, }; @@ -148,20 +151,16 @@ impl<'a> Link<'a> { }) } - pub(crate) fn replace_args>(&self, base: P) -> Result { + pub(crate) fn replace_args(&self, base: P, file_reader: &FR) -> Result + where + P: AsRef, + FR: FileReader, + { match self.link_type { LinkType::Escaped => Ok((&self.link_text[1..]).to_owned()), LinkType::Template(ref pat) => { let target = base.as_ref().join(pat); - - let contents = fs::read_to_string(&target).with_context(|| { - format!( - "Could not read template file {} ({})", - self.link_text, - target.display(), - ) - })?; - + let contents = file_reader.read_to_string(&target, self.link_text)?; Ok(Args::replace(contents.as_str(), &self.args)) } } @@ -245,11 +244,15 @@ impl<'a> Args<'a> { } fn from_capture(cap: Captures<'a>) -> Option> { + // https://regex101.com/r/lKSOOl/3 let arg_type = match (cap.get(0), cap.get(1), cap.get(2), cap.get(3)) { + // This looks like {{$path}} (_, Some(argument), None, None) => Some(ArgsType::Plain(argument.as_str())), + // This looks like {{$path ../images}} (_, _, Some(argument), Some(default_value)) => { Some(ArgsType::Default(argument.as_str(), default_value.as_str())) } + // This looks like \{{$any string}} (Some(mat), _, _, _) if mat.as_str().starts_with(ESCAPE_CHAR) => { Some(ArgsType::Escaped) } @@ -299,22 +302,6 @@ mod link_tests { use std::path::PathBuf; use crate::links::{extract_args, extract_template_links, Args, ArgsType, Link, LinkType}; - use crate::replace_template; - - #[test] - fn test_escaped_template_link() { - let start = r" - Example Text - ```hbs - \{{#template template.md}} << an escaped link! - ```"; - let end = r" - Example Text - ```hbs - {{#template template.md}} << an escaped link! - ```"; - assert_eq!(replace_template(start, "", "", 0), end); - } #[test] fn test_extract_zero_template_links() { @@ -322,12 +309,6 @@ mod link_tests { assert_eq!(extract_template_links(s).collect::>(), vec![]) } - #[test] - fn test_extract_zero_template_links_without_args() { - let s = "{{#template templates/footer.md}}"; - assert_eq!(extract_template_links(s).collect::>(), vec![]) - } - #[test] fn test_extract_template_links_partial_match() { let s = "Some random text with {{#template..."; @@ -342,7 +323,7 @@ mod link_tests { #[test] fn test_extract_template_links_empty() { - let s = "Some random text with {{#template}} and {{#template }} {{}} {{#}}..."; + let s = "Some random text with {{}} {{#}}..."; assert_eq!(extract_template_links(s).collect::>(), vec![]); } @@ -352,6 +333,24 @@ mod link_tests { assert!(extract_template_links(s).collect::>() == vec![]); } + #[test] + fn test_extract_zero_template_links_without_args() { + let s = "{{#template templates/footer.md}}"; + + let res = extract_template_links(s).collect::>(); + + assert_eq!( + res, + vec![Link { + start_index: 0, + end_index: 33, + link_type: LinkType::Template(PathBuf::from("templates/footer.md")), + link_text: "{{#template templates/footer.md}}", + args: HashMap::new() + },] + ); + } + #[test] fn test_extract_template_links_simple() { let s = @@ -361,13 +360,22 @@ mod link_tests { assert_eq!( res, - vec![Link { - start_index: 48, - end_index: 79, - link_type: LinkType::Template(PathBuf::from("test.rs")), - link_text: "{{#template test.rs lang=rust}}", - args: HashMap::from([("lang", "rust")]) - },] + vec![ + Link { + start_index: 22, + end_index: 43, + link_type: LinkType::Template(PathBuf::from("file.rs")), + link_text: "{{#template file.rs}}", + args: HashMap::new() + }, + Link { + start_index: 48, + end_index: 79, + link_type: LinkType::Template(PathBuf::from("test.rs")), + link_text: "{{#template test.rs lang=rust}}", + args: HashMap::from([("lang", "rust")]) + }, + ] ); } @@ -489,6 +497,26 @@ year=2022 ); } + #[test] + fn test_extract_template_links_with_newlines_malformed() { + let s = "{{#template test.rs + lang=rust + year=2022}}"; + + let res = extract_template_links(s).collect::>(); + + assert_eq!( + res, + vec![Link { + start_index: 0, + end_index: 58, + link_type: LinkType::Template(PathBuf::from("test.rs")), + link_text: "{{#template test.rs \n lang=rust\n year=2022}}", + args: HashMap::from([("lang", "rust"), ("year", "2022")]), + },] + ); + } + #[test] fn test_extract_zero_args() { let s = "This is some text without any template links";