diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/file_finder.rs | 86 | ||||
| -rw-r--r-- | src/file_handler/file_strategies/file.rs | 142 | ||||
| -rw-r--r-- | src/file_handler/file_strategies/gemini.rs | 220 | ||||
| -rw-r--r-- | src/file_handler/file_strategies/layout.rs | 117 | ||||
| -rw-r--r-- | src/file_handler/mod.rs | 191 | ||||
| -rw-r--r-- | src/gemini_parser.rs | 382 | ||||
| -rw-r--r-- | src/html_renderer.rs | 267 | ||||
| -rw-r--r-- | src/main.rs | 1 |
8 files changed, 1183 insertions, 223 deletions
diff --git a/src/file_finder.rs b/src/file_finder.rs index bcd368d..a94aa8a 100644 --- a/src/file_finder.rs +++ b/src/file_finder.rs @@ -28,3 +28,89 @@ fn find_files_recursively(root_path: &PathBuf, directory_path: &PathBuf) -> Vec< } return result; } + + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_handler::FileType; + use std::collections::HashSet; + use std::fs::create_dir_all; + + fn fixtures_dir() -> PathBuf { + PathBuf::from("tests/fixtures") + } + + fn get_paths(files: &Vec<File>) -> HashSet<String> { + files + .iter() + .map(|f| f.path.strip_prefix(&fixtures_dir()).unwrap().to_string_lossy().to_string()) + .collect() + } + + #[test] + fn finds_all_files() { + let files = find_files(&fixtures_dir()); + let paths = get_paths(&files); + + assert!(paths.contains("test1.gmi")); + assert!(paths.contains("_layout.html")); + assert!(paths.contains("nested/nested.gmi")); + assert!(paths.contains("assets/style.css")); + assert!(paths.contains("image.png")); + } + + #[test] + fn identifies_correct_file_types() { + let files = find_files(&fixtures_dir()); + + for file in files { + let extension = file.path.extension().and_then(|e| e.to_str()); + match extension { + Some("gmi") => assert_eq!(file.file_type, FileType::Gemini), + Some("html") => { + if file.path.ends_with("_layout.html") { + assert_eq!(file.file_type, FileType::Layout) + } else { + assert_eq!(file.file_type, FileType::File) + } + }, + _ => assert_eq!(file.file_type, FileType::File), + } + } + } + + #[test] + fn ignores_git_directory() { + let files = find_files(&fixtures_dir()); + let paths = get_paths(&files); + + // These files should exist in the fixtures but not be included + assert!(!paths.iter().any(|p| p.starts_with(".git/"))); + assert!(!paths.contains(".gitignore")); + } + + #[test] + fn handles_nested_directories() { + let files = find_files(&fixtures_dir()); + let paths = get_paths(&files); + + // Check that files in nested directories are found + assert!(paths.contains("nested/nested.gmi")); + assert!(paths.contains("assets/style.css")); + + // Verify directory structure is preserved in paths + let nested_files: Vec<_> = files.iter() + .filter(|f| f.path.starts_with(fixtures_dir().join("nested"))) + .collect(); + assert!(!nested_files.is_empty()); + } + + #[test] + fn returns_empty_for_empty_directory() { + let empty_dir = fixtures_dir().join("empty"); + let _ = create_dir_all(&empty_dir); + let files = find_files(&empty_dir); + assert!(files.is_empty()); + } +} diff --git a/src/file_handler/file_strategies/file.rs b/src/file_handler/file_strategies/file.rs index 2346128..5faa689 100644 --- a/src/file_handler/file_strategies/file.rs +++ b/src/file_handler/file_strategies/file.rs @@ -31,7 +31,7 @@ impl FileHandlerStrategy for Strategy { } } - fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, _l: &String) { + fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, _l: &str) { return self.handle(source, destination, file); } @@ -39,3 +39,143 @@ impl FileHandlerStrategy for Strategy { return self.handle(source, destination, file); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup() -> Strategy { + Strategy {} + } + + fn fixtures_dir() -> PathBuf { + PathBuf::from("tests/fixtures") + } + + fn fixture_path(filename: &str) -> PathBuf { + fixtures_dir().join(filename) + } + + mod file_type_tests { + use super::*; + + #[test] + fn identifies_regular_files() { + let strategy = setup(); + assert!(strategy.is(&fixture_path("image.png"))); + assert!(strategy.is(&fixture_path("style.css"))); + assert!(!strategy.is(&fixtures_dir())); + } + + #[test] + fn identifies_file_type() { + let strategy = setup(); + assert!(matches!(strategy.identify(), FileType::File)); + } + + #[test] + fn handles_correct_file_type() { + let strategy = setup(); + assert!(strategy.can_handle(&FileType::File)); + assert!(!strategy.can_handle(&FileType::Layout)); + assert!(!strategy.can_handle(&FileType::Gemini)); + assert!(!strategy.can_handle(&FileType::Unknown)); + } + } + + mod file_handling_tests { + use super::*; + + #[test] + fn copies_single_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + + let file = File { + path: fixture_path("image.png"), + file_type: FileType::File, + }; + + strategy.handle(&source, &output, &file); + + let copied_path = output.join("image.png"); + assert!(copied_path.exists()); + + // Verify file contents are identical + let original = fs::read(&file.path).unwrap(); + let copied = fs::read(&copied_path).unwrap(); + assert_eq!(original, copied); + + // Cleanup + let _ = fs::remove_file(copied_path); + } + + #[test] + fn copies_nested_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + + let file = File { + path: fixture_path("assets/style.css"), + file_type: FileType::File, + }; + + strategy.handle(&source, &output, &file); + + let copied_path = output.join("assets").join("style.css"); + assert!(copied_path.exists()); + + // Verify file contents are identical + let original = fs::read(&file.path).unwrap(); + let copied = fs::read(&copied_path).unwrap(); + assert_eq!(original, copied); + + // Cleanup + let _ = fs::remove_file(copied_path); + let _ = fs::remove_dir(output.join("assets")); + } + + #[test] + fn handle_html_copies_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output_html"); + + let file = File { + path: fixture_path("image.png"), + file_type: FileType::File, + }; + + strategy.handle_html(&source, &output, &file, "unused layout"); + + let copied_path = output.join("image.png"); + assert!(copied_path.exists()); + + // Cleanup + let _ = fs::remove_file(copied_path); + } + + #[test] + fn handle_gemini_copies_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output_gemini"); + + let file = File { + path: fixture_path("image.png"), + file_type: FileType::File, + }; + + strategy.handle_gemini(&source, &output, &file); + + let copied_path = output.join("image.png"); + assert!(copied_path.exists()); + + // Cleanup + let _ = fs::remove_file(copied_path); + } + } +} diff --git a/src/file_handler/file_strategies/gemini.rs b/src/file_handler/file_strategies/gemini.rs index 977464c..d8bde14 100644 --- a/src/file_handler/file_strategies/gemini.rs +++ b/src/file_handler/file_strategies/gemini.rs @@ -6,6 +6,7 @@ use std::fs::{create_dir_all, read_to_string, File as IOFile}; use crate::file_handler::{File, FileType, FileHandlerStrategy}; use crate::gemini_parser::parse; +use crate::html_renderer::render_html; impl Strategy { fn is_title(&self, line: &str) -> bool { @@ -44,7 +45,7 @@ impl FileHandlerStrategy for Strategy { } } - fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &String) { + fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &str) { let gemini_contents = read_to_string(&file.path).unwrap(); // Front matter extraction @@ -52,21 +53,23 @@ impl FileHandlerStrategy for Strategy { let mut lines_found = 0; let mut title = ""; let mut description = ""; - for line in lines[..2].iter() { - if self.is_title(&line) { - title = self.get_title(&line).trim(); - lines_found = lines_found + 1; - continue; - } - if self.is_description(&line) { - description = self.get_description(&line).trim(); - lines_found = lines_found + 1; - continue; + if let Some(slice) = lines.get(..2) { + for line in slice.iter() { + if self.is_title(&line) { + title = self.get_title(&line).trim(); + lines_found = lines_found + 1; + continue; + } + if self.is_description(&line) { + description = self.get_description(&line).trim(); + lines_found = lines_found + 1; + continue; + } } } let gemini_source = lines[lines_found..].join("\n"); - let content_html = parse(&gemini_source[..]); + let content_html = render_html(parse(&gemini_source[..])); let generated_html = layout .replace("{{ title }}", title) @@ -90,14 +93,16 @@ impl FileHandlerStrategy for Strategy { // Front matter extraction let lines: Vec<&str> = gemini_contents.split("\n").collect(); let mut lines_found = 0; - for line in lines[..2].iter() { - if self.is_title(&line) { - lines_found = lines_found + 1; - continue; - } - if self.is_description(&line) { - lines_found = lines_found + 1; - continue; + if let Some(slice) = lines.get(..2) { + for line in slice.iter() { + if self.is_title(&line) { + lines_found = lines_found + 1; + continue; + } + if self.is_description(&line) { + lines_found = lines_found + 1; + continue; + } } } @@ -112,3 +117,178 @@ impl FileHandlerStrategy for Strategy { destination_file.write_all(gemini_source.as_bytes()).unwrap(); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup() -> Strategy { + Strategy {} + } + + fn fixtures_dir() -> PathBuf { + PathBuf::from("tests/fixtures") + } + + fn fixture_path(filename: &str) -> PathBuf { + fixtures_dir().join(filename) + } + + fn read_fixture(filename: &str) -> String { + fs::read_to_string(fixture_path(filename)) + .unwrap_or_else(|_| panic!("Failed to read fixture file: {}", filename)) + } + + mod front_matter_tests { + use super::*; + + #[test] + fn detects_title() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let first_line = content.lines().next().unwrap(); + assert!(strategy.is_title(first_line)); + } + + #[test] + fn detects_description() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let second_line = content.lines().nth(1).unwrap(); + assert!(strategy.is_description(second_line)); + } + + #[test] + fn extracts_title() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let first_line = content.lines().next().unwrap(); + assert_eq!(strategy.get_title(first_line).trim(), "Test Title"); + } + + #[test] + fn extracts_description() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let second_line = content.lines().nth(1).unwrap(); + assert_eq!(strategy.get_description(second_line).trim(), "Test Description"); + } + } + + mod file_type_tests { + use super::*; + + #[test] + fn identifies_gemini_files() { + let strategy = setup(); + assert!(strategy.is(&fixture_path("test1.gmi"))); + assert!(!strategy.is(&fixture_path("_layout.html"))); + assert!(!strategy.is(&fixtures_dir())); + } + + #[test] + fn identifies_file_type() { + let strategy = setup(); + assert!(matches!(strategy.identify(), FileType::Gemini)); + } + + #[test] + fn handles_correct_file_type() { + let strategy = setup(); + assert!(strategy.can_handle(&FileType::Gemini)); + assert!(!strategy.can_handle(&FileType::Layout)); + assert!(!strategy.can_handle(&FileType::File)); + assert!(!strategy.can_handle(&FileType::Unknown)); + } + } + + mod file_handling_tests { + use super::*; + + #[test] + fn handles_html_generation() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + let layout = read_fixture("_layout.html"); + + let file = File { + path: fixture_path("test1.gmi"), + file_type: FileType::Gemini, + }; + + strategy.handle_html( + &source, + &output, + &file, + &layout, + ); + + let generated_path = output.join("test1.html"); + assert!(generated_path.exists()); + + let content = fs::read_to_string(generated_path.clone()).unwrap(); + assert!(content.contains("Test Title")); + assert!(content.contains("<h1>")); + + // Cleanup + let _ = fs::remove_file(generated_path); + } + + #[test] + fn handles_gemini_generation() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + + let file = File { + path: fixture_path("test1.gmi"), + file_type: FileType::Gemini, + }; + + strategy.handle_gemini( + &source, + &output, + &file, + ); + + let generated_path = output.join("test1.gmi"); + assert!(generated_path.exists()); + + let content = fs::read_to_string(&generated_path).unwrap(); + assert!(content.contains("# Heading")); + assert!(!content.contains("Test Title")); // Front matter should be removed + + // Cleanup + let _ = fs::remove_file(generated_path); + } + + #[test] + fn handles_nested_structure() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + let layout = read_fixture("_layout.html"); + + let file = File { + path: fixture_path("nested/nested.gmi"), + file_type: FileType::Gemini, + }; + + strategy.handle_html( + &source, + &output, + &file, + &layout, + ); + + let generated_path = output.join("nested").join("nested.html"); + assert!(generated_path.exists()); + + // Cleanup + let _ = fs::remove_file(generated_path); + let _ = fs::remove_dir(output.join("nested")); + } + } +} diff --git a/src/file_handler/file_strategies/layout.rs b/src/file_handler/file_strategies/layout.rs index f51bf7a..44b5000 100644 --- a/src/file_handler/file_strategies/layout.rs +++ b/src/file_handler/file_strategies/layout.rs @@ -22,6 +22,121 @@ impl FileHandlerStrategy for Strategy { // We don't implement handling for layout, as we assume there's only one // and it got handled before. - fn handle_html(&self, _s: &PathBuf, _d: &PathBuf, _f: &File, _l: &String) {} + fn handle_html(&self, _s: &PathBuf, _d: &PathBuf, _f: &File, _l: &str) {} fn handle_gemini(&self, _s: &PathBuf, _d: &PathBuf, _f: &File) {} } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn setup() -> Strategy { + Strategy {} + } + + mod is_tests { + use super::*; + + #[test] + fn identifies_layout_file() { + let strategy = setup(); + let path = PathBuf::from("_layout.html"); + assert!(strategy.is(&path)); + } + + #[test] + fn rejects_non_layout_html() { + let strategy = setup(); + let path = PathBuf::from("regular.html"); + assert!(!strategy.is(&path)); + } + + #[test] + fn rejects_layout_with_different_extension() { + let strategy = setup(); + let path = PathBuf::from("_layout.txt"); + assert!(!strategy.is(&path)); + } + + #[test] + fn rejects_layout_with_prefix() { + let strategy = setup(); + let path = PathBuf::from("prefix_layout.html"); + assert!(!strategy.is(&path)); + } + + #[test] + fn rejects_directory_named_layout() { + let strategy = setup(); + let path = PathBuf::from("tests/fixtures"); + + assert!(!strategy.is(&path)); + } + } + + mod identify_tests { + use super::*; + + #[test] + fn returns_layout_type() { + let strategy = setup(); + assert!(matches!(strategy.identify(), FileType::Layout)); + } + } + + mod can_handle_tests { + use super::*; + + #[test] + fn handles_layout_type() { + let strategy = setup(); + assert!(strategy.can_handle(&FileType::Layout)); + } + + #[test] + fn rejects_non_layout_types() { + let strategy = setup(); + assert!(!strategy.can_handle(&FileType::File)); + assert!(!strategy.can_handle(&FileType::Gemini)); + assert!(!strategy.can_handle(&FileType::Unknown)); + } + } + + mod handle_methods { + use super::*; + + #[test] + fn handle_html_does_nothing() { + let strategy = setup(); + let file = File { + path: PathBuf::from("test.html"), + file_type: FileType::Layout, + }; + + // Should not panic + strategy.handle_html( + &PathBuf::from("source"), + &PathBuf::from("dest"), + &file, + "layout content" + ); + } + + #[test] + fn handle_gemini_does_nothing() { + let strategy = setup(); + let file = File { + path: PathBuf::from("test.gmi"), + file_type: FileType::Layout, + }; + + // Should not panic + strategy.handle_gemini( + &PathBuf::from("source"), + &PathBuf::from("dest"), + &file + ); + } + } +} diff --git a/src/file_handler/mod.rs b/src/file_handler/mod.rs index 9a0133a..8ba50d4 100644 --- a/src/file_handler/mod.rs +++ b/src/file_handler/mod.rs @@ -35,7 +35,7 @@ impl FileHandler { FileType::Unknown } - pub fn get_layout_or_panic(&mut self, files: &Vec<File>) -> Result<(), &str> { + pub fn get_layout_or_panic(&mut self, files: &[File]) -> Result<(), &str> { for file in files { match file.file_type { FileType::Layout => { @@ -49,20 +49,22 @@ impl FileHandler { Err("No layout found. Please ensure there's a _layout.html file at the root") } - pub fn handle_all(&self, source: &PathBuf, html_destination: &PathBuf, gemini_destination: &PathBuf, files: &Vec<File>) { - for file in files { + pub fn handle_all(&self, source: &PathBuf, html_destination: &PathBuf, gemini_destination: &PathBuf, files: &[File]) { + files.iter().for_each(|file| { self.handle(source, html_destination, gemini_destination, file); - } + }); } pub fn handle(&self, source: &PathBuf, html_destination: &PathBuf, gemini_destination: &PathBuf, file: &File) { - for strategy in self.strategies.iter() { - if strategy.can_handle(&file.file_type) { - let layout = self.layout.as_ref().unwrap(); + if let Some(strategy) = self.strategies + .iter() + .find(|s| s.can_handle(&file.file_type)) + { + let layout = self.layout.as_ref() + .expect("Layout should be initialized before handling files"); strategy.handle_html(source, html_destination, file, layout); strategy.handle_gemini(source, gemini_destination, file); return; - } } } } @@ -71,10 +73,11 @@ pub trait FileHandlerStrategy { fn is(&self, path: &PathBuf) -> bool; fn identify(&self) -> FileType; fn can_handle(&self, file_type: &FileType) -> bool; - fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &String); + fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &str); fn handle_gemini(&self, source: &PathBuf, destination: &PathBuf, file: &File); } +#[derive(Debug, Clone, PartialEq)] pub enum FileType { Gemini, File, @@ -82,7 +85,177 @@ pub enum FileType { Unknown, } +#[derive(PartialEq, Debug)] pub struct File { pub path: PathBuf, pub file_type: FileType, } + + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn create_test_file(path: &str, file_type: FileType) -> File { + File { + path: PathBuf::from(path), + file_type, + } + } + + #[test] + fn test_identify_gemini_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("test.gmi"); + assert!(matches!(handler.identify(&path), FileType::Gemini)); + } + + #[test] + fn test_identify_layout_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("_layout.html"); + assert!(matches!(handler.identify(&path), FileType::Layout)); + } + + #[test] + fn test_identify_regular_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("regular.html"); + assert!(matches!(handler.identify(&path), FileType::File)); + } + + #[test] + fn test_identify_unknown_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("tests/fixtures"); + assert!(matches!(handler.identify(&path), FileType::Unknown)); + } + + #[test] + fn test_get_layout_success() { + let mut handler = FileHandler::default(); + let files = vec![ + create_test_file("test.gmi", FileType::Gemini), + create_test_file("tests/fixtures/_layout.html", FileType::Layout), + create_test_file("regular.html", FileType::File), + ]; + + assert!(handler.get_layout_or_panic(&files).is_ok()); + } + + #[test] + fn test_get_layout_failure() { + let mut handler = FileHandler::default(); + let files = vec![ + create_test_file("test.gmi", FileType::Gemini), + create_test_file("regular.html", FileType::File), + ]; + + assert!(handler.get_layout_or_panic(&files).is_err()); + } + + // Mock strategy for testing + struct MockStrategy { + is_match: bool, + file_type: FileType, + } + + impl FileHandlerStrategy for MockStrategy { + fn is(&self, _path: &PathBuf) -> bool { + self.is_match + } + + fn identify(&self) -> FileType { + self.file_type.clone() + } + + fn can_handle(&self, file_type: &FileType) -> bool { + &self.file_type == file_type + } + + fn handle_html(&self, _source: &PathBuf, _destination: &PathBuf, _file: &File, _layout: &str) {} + fn handle_gemini(&self, _source: &PathBuf, _destination: &PathBuf, _file: &File) {} + } + + #[test] + fn test_custom_strategy() { + let mock_strategy = MockStrategy { + is_match: true, + file_type: FileType::Gemini, + }; + + let handler = FileHandler { + strategies: vec![Box::new(mock_strategy)], + layout: None, + }; + + let path = PathBuf::from("test.whatever"); + assert!(matches!(handler.identify(&path), FileType::Gemini)); + } + + #[test] + fn test_handle_all_empty_files() { + let handler = FileHandler::default(); + let files: Vec<File> = vec![]; + + // Should not panic with empty vector + handler.handle_all( + &PathBuf::from("source"), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &files + ); + } + + #[test] + fn test_handle_with_layout() { + let mut handler = FileHandler::default(); + handler.layout = Some("test layout".to_string()); + + let file = create_test_file("tests/fixtures/test1.gmi", FileType::Gemini); + + // Should not panic with valid layout + handler.handle( + &PathBuf::from(""), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &file + ); + } + + #[test] + #[should_panic(expected = "Layout should be initialized before handling files")] + fn test_handle_without_layout() { + let handler = FileHandler::default(); + let file = create_test_file("test.gmi", FileType::Gemini); + + handler.handle( + &PathBuf::from("source"), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &file + ); + } + + #[test] + fn test_slice_handling() { + let mut handler = FileHandler::default(); + let files = vec![ + create_test_file("tests/fixtures/test1.gmi", FileType::Gemini), + create_test_file("tests/fixtures/_layout.html", FileType::Layout), + create_test_file("tests/fixtures/test2.gmi", FileType::Gemini), + create_test_file("tests/fixtures/test3.gmi", FileType::Gemini), + ]; + + let _ = handler.get_layout_or_panic(&files[1..]); + + // Test with slice + handler.handle_all( + &PathBuf::from(""), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &files[1..] // Test with slice of last three elements + ); + } +} diff --git a/src/gemini_parser.rs b/src/gemini_parser.rs index 496dc0d..418b758 100644 --- a/src/gemini_parser.rs +++ b/src/gemini_parser.rs @@ -1,224 +1,222 @@ - pub fn parse(source: &str) -> String { - - let lines = source.split("\n"); - let mut is_preformatted = false; - - let mut block_label: Option<String> = None; - let mut html: String = "".to_owned(); - let mut current_line_type: Option<LineType> = None; +#[derive(PartialEq, Eq, Debug)] +pub enum GeminiLine { + Text(String, bool), + PreformattedToggle(bool, String), + Heading(u8, String), + Link(String, String), + Quote(String), + ListItem(String) +} - let mut heading_stack: Vec<u8> = Vec::new(); - for line in lines { - let mut line_type = LineType::Blank; - if line.char_indices().count() > 2 { - let mut end = line.len(); - if line.char_indices().count() > 3 { - end = line.char_indices().map(|(i, _)| i).nth(3).unwrap(); - } - line_type = identify_line(&line[..end], is_preformatted); - } - match line_type { - LineType::PreformattedToggle => { - is_preformatted = !is_preformatted; - if is_preformatted && line.char_indices().count() > 3 { - block_label = Some(get_partial_line_content(&line_type, line)); +/// Parses gemtext source code into a vector of GeminiLine elements. +/// +/// # Arguments +/// * `source` - A string slice that contains the gemtext +/// +/// # Returns +/// A `Vec<GeminiLine>` containing the rendered HTML. +pub fn parse(source: &str) -> Vec<GeminiLine> { + source.lines() + .fold( + (Vec::new(), false), + |(mut lines, is_preformatted), line| { + let parsed = if is_preformatted { + parse_preformatted_line(line) } else { - block_label = None; - } - }, - _ => { - // Close previous block if needed - if let Some(line) = ¤t_line_type { - if line != &line_type && is_block(line) { - html.push_str(get_line_closer(line)); - } - } - - // Blocks - if is_block(&line_type) { - if let Some(line) = ¤t_line_type { - if line != &line_type { - html.push_str(&get_line_opener(&line_type, block_label.as_ref())); - } - } else { - html.push_str(&get_line_opener(&line_type, None)); - } + parse_line(line) + }; - let line_content = get_partial_line_content(&line_type, line); - html.push_str(&line_content); - } else { - html.push_str(&get_heading_wrapper(&mut heading_stack, &line_type)); - html.push_str(&get_full_line_content(&line_type, line)); - } - current_line_type = Some(line_type); - }, - } - } - if let Some(line) = ¤t_line_type { - if is_block(line) { - html.push_str(get_line_closer(line)); - } - } - html.push_str(&close_heading_wrapper(&mut heading_stack)); - html -} + let new_is_preformatted = match parsed { + GeminiLine::PreformattedToggle(x, _) => x, + _ => is_preformatted + }; -fn is_block(line_type: &LineType) -> bool { - return match line_type { - LineType::PreformattedText | LineType::ListItem | LineType::Quote => true, - _ => false, - } + lines.push(parsed); + (lines, new_is_preformatted) + } + ) + .0 } -fn get_partial_line_content(line_type: &LineType, line: &str) -> String { - let encoded_line = line.replace("<", "<").replace(">", ">"); - return match line_type { - LineType::ListItem => format!("<li>{}</li>", encoded_line[2..].trim()), - LineType::Quote => encoded_line[1..].trim().to_string(), - LineType::PreformattedText => format!("{}\n", encoded_line), - LineType::PreformattedToggle => encoded_line[3..].trim().to_string(), - _ => "".to_string(), +fn parse_preformatted_line(line: &str) -> GeminiLine { + match line { + s if s.starts_with("```") => GeminiLine::PreformattedToggle(false, String::new()), + _ => GeminiLine::Text(line.to_string(), true), } } -fn get_full_line_content(line_type: &LineType, line: &str) -> String { - let encoded_line = line.replace("<", "<").replace(">", ">"); - match line_type { - LineType::Text => format!("<p>{}</p>\n", encoded_line.trim()), - LineType::Blank => "<br>\n".to_string(), - LineType::Link => { - let url = get_link_address(line); - if url.starts_with("gemini:") { - format!("<p class=\"a\"><a href=\"{}\">{}</a></p>\n", url, get_link_content(line)) - } else { - format!("<p class=\"a\"><a href=\"{}\">{}</a></p>\n", url.replace(".gmi", ".html"), get_link_content(line)) +fn parse_line(line: &str) -> GeminiLine { + match line { + s if s.starts_with("###") => GeminiLine::Heading(3, s[3..].to_string()), + s if s.starts_with("##") => GeminiLine::Heading(2, s[2..].to_string()), + s if s.starts_with("#") => GeminiLine::Heading(1, s[1..].to_string()), + s if s.starts_with("=>") => { + let content = s[2..].trim(); + match content.split_once(char::is_whitespace) { + Some((url, text)) => GeminiLine::Link(url.trim().to_string(), text.trim().to_string()), + None => GeminiLine::Link(content.trim().to_string(), String::new()), } }, - LineType::Heading1 => format!("<h1>{}</h1>\n", encoded_line[1..].trim()), - LineType::Heading2 => format!("<h2>{}</h2>\n", encoded_line[2..].trim()), - LineType::Heading3 => format!("<h3>{}</h3>\n", encoded_line[3..].trim()), - _ => "".to_string(), + s if s.starts_with("* ") => GeminiLine::ListItem(s[2..].to_string()), + s if s.starts_with(">") => GeminiLine::Quote(s[1..].to_string()), + s if s.starts_with("```") => GeminiLine::PreformattedToggle(true, s[3..].to_string()), + _ => GeminiLine::Text(line.to_string(), false), } } -fn get_heading_wrapper(heading_stack: &mut Vec<u8>, line_type: &LineType) -> String { - let mut string = String::new(); - let current_heading: u8 = match line_type { - LineType::Heading1 => 1, - LineType::Heading2 => 2, - LineType::Heading3 => 3, - _ => 255 - }; - - if current_heading < 255 { - while let Some(open_heading) = heading_stack.pop() { - // You just encountered a more important heading. - // Put it back. Desist. - if open_heading < current_heading { - heading_stack.push(open_heading); - break; - } +#[cfg(test)] +mod tests { + use super::*; - string.push_str("</section>"); + #[test] + fn test_headings() { + assert_eq!(parse_line("### Heading"), GeminiLine::Heading(3, " Heading".to_string())); + assert_eq!(parse_line("## Heading"), GeminiLine::Heading(2, " Heading".to_string())); + assert_eq!(parse_line("# Heading"), GeminiLine::Heading(1, " Heading".to_string())); + assert_eq!(parse_line("###"), GeminiLine::Heading(3, "".to_string())); + assert_eq!(parse_line("#####"), GeminiLine::Heading(3, "##".to_string())); + assert_eq!(parse_line("# "), GeminiLine::Heading(1, " ".to_string())); - if open_heading == current_heading { - break; - } - } - heading_stack.push(current_heading); - string.push_str(&format!("<section class=\"h{}\">", current_heading)); + assert_eq!(parse_preformatted_line("### Heading"), GeminiLine::Text("### Heading".to_string(), true)); + assert_eq!(parse_preformatted_line("## Heading"), GeminiLine::Text("## Heading".to_string(), true)); + assert_eq!(parse_preformatted_line("# Heading"), GeminiLine::Text("# Heading".to_string(), true)); } - return string; -} + #[test] + fn test_links() { + assert_eq!( + parse_line("=> https://example.com Link text"), + GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()) + ); + assert_eq!( + parse_line("=> /local/path"), + GeminiLine::Link("/local/path".to_string(), "".to_string()) + ); -fn close_heading_wrapper(heading_stack: &mut Vec<u8>) -> String { - let mut string = String::new(); - while let Some(_open_heading) = heading_stack.pop() { - string.push_str("</section>"); - } - return string; -} + assert_eq!( + parse_line("=>"), + GeminiLine::Link("".to_string(), "".to_string()) + ); + assert_eq!( + parse_line("=> "), + GeminiLine::Link("".to_string(), "".to_string()) + ); + assert_eq!( + parse_line("=> multiple spaces in text"), + GeminiLine::Link("multiple".to_string(), "spaces in text".to_string()) + ); -fn get_line_opener(line_type: &LineType, block_label: Option<&String>) -> String { - match line_type { - LineType::ListItem => "<ul>".to_string(), - LineType::Quote => "<blockquote>".to_string(), - LineType::PreformattedText => { - if let Some(label) = &block_label { - return format!("<pre role=\"img\" aria-label=\"{}\">", label); - } else { - return "<pre>".to_string(); - } - }, - _ => "".to_string(), + assert_eq!( + parse_preformatted_line("=> https://example.com Link text"), + GeminiLine::Text("=> https://example.com Link text".to_string(), true) + ); } -} -fn get_line_closer(line_type: &LineType) -> &'static str { - match line_type { - LineType::ListItem => "</ul>\n", - LineType::Quote => "</blockquote>\n", - LineType::PreformattedText => "</pre>\n", - _ => "", - } -} + #[test] + fn test_list_items() { + assert_eq!( + parse_line("* List item"), + GeminiLine::ListItem("List item".to_string()) + ); -fn get_link_content(line: &str) -> &str { - let components: Vec<&str> = line[2..].trim().splitn(2, " ").collect(); - if components.len() > 1 { - return components[1].trim() + assert_eq!(parse_line("* "), GeminiLine::ListItem("".to_string())); + assert_eq!(parse_line("*"), GeminiLine::Text("*".to_string(), false)); + assert_eq!(parse_line("*WithText"), GeminiLine::Text("*WithText".to_string(), false)); + assert_eq!(parse_line("* Multiple spaces"), GeminiLine::ListItem(" Multiple spaces".to_string())); } - components[0].trim() -} -fn get_link_address(line: &str) -> &str { - let components: Vec<&str> = line[2..].trim().splitn(2, " ").collect(); - components[0].trim() -} + #[test] + fn test_quotes() { + assert_eq!( + parse_line(">Quote text"), + GeminiLine::Quote("Quote text".to_string()) + ); -fn identify_line(line: &str, is_preformatted: bool) -> LineType { - if line.starts_with("```") { - return LineType::PreformattedToggle; - } - if is_preformatted { - return LineType::PreformattedText; - } - if line.is_empty() { - return LineType::Blank; - } - if line.starts_with("=>") { - return LineType::Link; - } - if line.starts_with("* ") { - return LineType::ListItem; - } - if line.starts_with(">") { - return LineType::Quote; - } - if line.starts_with("###") { - return LineType::Heading3; + assert_eq!(parse_line(">"), GeminiLine::Quote("".to_string())); + assert_eq!(parse_line("> "), GeminiLine::Quote(" ".to_string())); + assert_eq!(parse_line(">>Nested"), GeminiLine::Quote(">Nested".to_string())); } - if line.starts_with("##") { - return LineType::Heading2; + + #[test] + fn test_preformatted() { + assert_eq!( + parse_line("```alt-text"), + GeminiLine::PreformattedToggle(true, "alt-text".to_string()) + ); + + assert_eq!(parse_line("```"), GeminiLine::PreformattedToggle(true, "".to_string())); + assert_eq!(parse_line("``` "), GeminiLine::PreformattedToggle(true, " ".to_string())); + assert_eq!(parse_line("````"), GeminiLine::PreformattedToggle(true, "`".to_string())); + + assert_eq!( + parse_preformatted_line("```alt-text"), + GeminiLine::PreformattedToggle(false, "".to_string()) + ); + assert_eq!( + parse_preformatted_line("```"), + GeminiLine::PreformattedToggle(false, "".to_string()) + ); + } - if line.starts_with("#") { - return LineType::Heading1; + + #[test] + fn test_text() { + // Normal case + assert_eq!( + parse_line("Regular text"), + GeminiLine::Text("Regular text".to_string(), false) + ); + + // Edge cases + assert_eq!(parse_line(""), GeminiLine::Text("".to_string(), false)); + assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); + assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); } - LineType::Text -} + #[test] + fn test_malformed_input() { + assert_eq!(parse_line("= >Not a link"), GeminiLine::Text("= >Not a link".to_string(), false)); + assert_eq!(parse_line("``Not preformatted"), GeminiLine::Text("``Not preformatted".to_string(), false)); + assert_eq!(parse_line("** Not a list"), GeminiLine::Text("** Not a list".to_string(), false)); + } -#[derive(PartialEq, Eq)] -enum LineType { - Text, - Blank, - Link, - PreformattedToggle, - PreformattedText, - Heading1, - Heading2, - Heading3, - ListItem, - Quote + #[test] + fn test_full_document() { + let input = "\ +# Heading 1 +## Heading 2 +### Heading 3 +Regular text +=> https://example.com Link text +* List item +>Quote +```alt +code +# Heading 1 +## Heading 2 +### Heading 3 +=> https://example.com Link text +* List item +>Quote +```trailing alt"; + let result = parse(input); + assert_eq!(result, vec![ + GeminiLine::Heading(1, " Heading 1".to_string()), + GeminiLine::Heading(2, " Heading 2".to_string()), + GeminiLine::Heading(3, " Heading 3".to_string()), + GeminiLine::Text("Regular text".to_string(), false), + GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()), + GeminiLine::ListItem("List item".to_string()), + GeminiLine::Quote("Quote".to_string()), + GeminiLine::PreformattedToggle(true, "alt".to_string()), + GeminiLine::Text("code".to_string(), true), + GeminiLine::Text("# Heading 1".to_string(), true), + GeminiLine::Text("## Heading 2".to_string(), true), + GeminiLine::Text("### Heading 3".to_string(), true), + GeminiLine::Text("=> https://example.com Link text".to_string(), true), + GeminiLine::Text("* List item".to_string(), true), + GeminiLine::Text(">Quote".to_string(), true), + GeminiLine::PreformattedToggle(false, "".to_string()), + ]); + } } diff --git a/src/html_renderer.rs b/src/html_renderer.rs new file mode 100644 index 0000000..47d36bb --- /dev/null +++ b/src/html_renderer.rs @@ -0,0 +1,267 @@ +use crate::gemini_parser::GeminiLine; + +/// Renders HTML from a vector of GeminiLine elements. +/// +/// # Arguments +/// * `lines` - Vector of GeminiLine elements to render +/// +/// # Returns +/// A String containing the rendered HTML. +pub fn render_html(lines: Vec<GeminiLine>) -> String { + let mut heading_stack = Vec::new(); + let mut last_line: Option<&GeminiLine> = None; + let mut result = String::new(); + + for line in &lines { + result.push_str(&line_preamble(line, last_line, &mut heading_stack)); + result.push_str(&line_content(line)); + result.push('\n'); + last_line = Some(line); + } + + result.push_str(&finalize_html(&heading_stack, last_line)); + result +} + +fn line_preamble( + line: &GeminiLine, + last_line: Option<&GeminiLine>, + heading_stack: &mut Vec<u8> +) -> String { + let mut html = String::new(); + + if let Some(last_line) = last_line { + match last_line { + GeminiLine::ListItem(_) => { + match line { + GeminiLine::ListItem(_) => {}, + _ => html.push_str("</ul>\n"), + } + }, + GeminiLine::Quote(_) => { + match line { + GeminiLine::Quote(_) => {}, + _ => html.push_str("</blockquote>\n"), + } + }, + _ => {} + } + } + + match line { + GeminiLine::Heading(level, _) => { + while let Some(open_heading) = heading_stack.pop() { + // You just encountered a more important heading. + // Put it back. Desist. + if open_heading < *level { + heading_stack.push(open_heading); + break; + } + + html.push_str("</section>\n"); + + if open_heading == *level { + break; + } + } + heading_stack.push(*level); + html.push_str(&format!("<section class=\"h{}\">\n", level)); + }, + GeminiLine::ListItem(_) => { + match last_line { + Some(GeminiLine::ListItem(_)) => {}, + _ => html.push_str("<ul>\n"), + } + }, + GeminiLine::Quote(_) => { + match last_line { + Some(GeminiLine::Quote(_)) => {}, + _ => html.push_str("<blockquote>\n"), + } + }, + _ => {} + } + + html +} + +fn line_content(line: &GeminiLine) -> String { + match line { + GeminiLine::Text(content, false) => format!("<p>{}</p>", content), + GeminiLine::Link(url, text) => { + let display = if text.is_empty() { url } else { text }; + format!("<p class=\"a\"><a href=\"{}\">{}</a></p>", url, display) + }, + GeminiLine::Heading(level, content) => format!("<h{}>{}</h{}>", level, content, level), + GeminiLine::ListItem(content) => format!("<li>{}</li>", content), + GeminiLine::PreformattedToggle(true, alt_text) => { format!("<pre aria-label=\"{}\">", alt_text) } + GeminiLine::PreformattedToggle(false, _) => { "</pre>".to_string() } + GeminiLine::Text(content, true) | GeminiLine::Quote(content) => format!("{}", content) + } +} + +fn finalize_html(heading_stack: &[u8], last_line: Option<&GeminiLine>) -> String { + let mut html = String::new(); + + match last_line { + Some(GeminiLine::ListItem(_)) => html.push_str("</ul>\n"), + Some(GeminiLine::Quote(_)) => html.push_str("</blockquote>\n"), + _ => {} + } + + for _ in heading_stack { + html.push_str("</section>\n"); + } + + html +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_text() { + let input = vec![ + GeminiLine::Text("Hello world".to_string(), false), + ]; + assert_eq!( + render_html(input), + "<p>Hello world</p>\n" + ); + } + + #[test] + fn test_heading_nesting() { + let input = vec![ + GeminiLine::Heading(1, "Top".to_string()), + GeminiLine::Heading(2, "Sub".to_string()), + GeminiLine::Heading(3, "SubSub".to_string()), + GeminiLine::Heading(2, "Another Sub".to_string()), + ]; + assert_eq!( + render_html(input), + "<section class=\"h1\">\n\ + <h1>Top</h1>\n\ + <section class=\"h2\">\n\ + <h2>Sub</h2>\n\ + <section class=\"h3\">\n\ + <h3>SubSub</h3>\n\ + </section>\n\ + </section>\n\ + <section class=\"h2\">\n\ + <h2>Another Sub</h2>\n\ + </section>\n\ + </section>\n" + ); + } + + #[test] + fn test_list_transitions() { + let input = vec![ + GeminiLine::ListItem("First".to_string()), + GeminiLine::ListItem("Second".to_string()), + GeminiLine::Text("Break".to_string(), false), + GeminiLine::ListItem("New list".to_string()), + ]; + assert_eq!( + render_html(input), + "<ul>\n\ + <li>First</li>\n\ + <li>Second</li>\n\ + </ul>\n\ + <p>Break</p>\n\ + <ul>\n\ + <li>New list</li>\n\ + </ul>\n" + ); + } + + #[test] + fn test_quote_transitions() { + let input = vec![ + GeminiLine::Quote("First quote".to_string()), + GeminiLine::Quote("Still quoting".to_string()), + GeminiLine::Text("Normal text".to_string(), false), + GeminiLine::Quote("New quote".to_string()), + ]; + assert_eq!( + render_html(input), + "<blockquote>\n\ + First quote\n\ + Still quoting\n\ + </blockquote>\n\ + <p>Normal text</p>\n\ + <blockquote>\n\ + New quote\n\ + </blockquote>\n" + ); + } + + #[test] + fn test_preformatted() { + let input = vec![ + GeminiLine::PreformattedToggle(true, "code".to_string()), + GeminiLine::Text("let x = 42;".to_string(), true), + GeminiLine::PreformattedToggle(false, String::new()), + ]; + assert_eq!( + render_html(input), + "<pre aria-label=\"code\">\n\ + let x = 42;\n\ + </pre>\n" + ); + } + + #[test] + fn test_links() { + let input = vec![ + GeminiLine::Link("https://example.com".to_string(), "Example".to_string()), + GeminiLine::Link("https://rust-lang.org".to_string(), "".to_string()), + ]; + assert_eq!( + render_html(input), + "<p class=\"a\"><a href=\"https://example.com\">Example</a></p>\n\ + <p class=\"a\"><a href=\"https://rust-lang.org\">https://rust-lang.org</a></p>\n" + ); + } + + #[test] + fn test_complex_nesting() { + let input = vec![ + GeminiLine::Heading(1, "Title".to_string()), + GeminiLine::Text("Intro".to_string(), false), + GeminiLine::Heading(2, "Section".to_string()), + GeminiLine::ListItem("Point 1".to_string()), + GeminiLine::ListItem("Point 2".to_string()), + GeminiLine::Quote("Important quote".to_string()), + GeminiLine::Heading(2, "Another Section".to_string()), + ]; + assert_eq!( + render_html(input), + "<section class=\"h1\">\n\ + <h1>Title</h1>\n\ + <p>Intro</p>\n\ + <section class=\"h2\">\n\ + <h2>Section</h2>\n\ + <ul>\n\ + <li>Point 1</li>\n\ + <li>Point 2</li>\n\ + </ul>\n\ + <blockquote>\n\ + Important quote\n\ + </blockquote>\n\ + </section>\n\ + <section class=\"h2\">\n\ + <h2>Another Section</h2>\n\ + </section>\n\ + </section>\n" + ); + } + + #[test] + fn test_empty_input() { + let input = Vec::new(); + assert_eq!(render_html(input), ""); + } +} diff --git a/src/main.rs b/src/main.rs index fc085d8..533a6bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod gemini_parser; +mod html_renderer; mod file_finder; mod file_handler; |