#[derive(PartialEq, Eq, Debug)] /// Represents one of the six line types in gemtext. /// /// # Examples /// ``` /// use gema_texto::gemini_parser::GeminiLine; /// use gema_texto::gemini_parser::parse; /// /// let gemini_source = "\ /// # Hello /// This is some gemini text! /// => https://test Go to the test page. /// /// This text actually has more linkes /// => https://test2 Go to the second test page. /// => https://test3 Go to the third test page. /// "; /// /// let gemini_lines = parse(&gemini_source); /// // Count the number of links /// let link_count = gemini_lines.iter() /// .filter(|line| matches!(line, GeminiLine::Link(_, _))) /// .count(); /// assert_eq!(link_count, 3); /// ``` pub enum GeminiLine { /// Text line, with a bool that shows whether it's preformatted. Text(String, bool), /// Preformatted toggle line with alt text. /// /// The boolean indicates whether it turned preformatted mode on or off. /// The string is the optional alt-text. PreformattedToggle(bool, String), /// Heading with a number between 1 and 3 inclusive indicating level. Heading(u8, String), /// Link with URL and text. Link(String, String), /// Quote Quote(String), /// List item ListItem(String), } /// Parses gemtext source code into a vector of `GeminiLine` elements. /// /// # Arguments /// * `source` - A string slice that contains the gemtext /// /// # Returns /// A `Vec` containing the rendered HTML. /// /// # Examples /// /// ``` /// use std::fs::read_to_string; /// use gema_texto::gemini_parser::parse; /// let gemini_source = "\ /// # Hello /// This is some gemini text! /// => https://test Go to the test page. /// "; /// /// let gemini_lines = parse(&gemini_source); /// /// // Prints the different lines in the array. /// for line in gemini_lines { /// println!("{:#?}", line); /// } /// ``` #[must_use] pub fn parse(source: &str) -> Vec { source .lines() .fold((Vec::new(), false), |(mut lines, is_preformatted), line| { let parsed = if is_preformatted { parse_preformatted_line(line) } else { parse_line(line) }; let new_is_preformatted = match parsed { GeminiLine::PreformattedToggle(x, _) => x, _ => is_preformatted, }; lines.push(parsed); (lines, new_is_preformatted) }) .0 } 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 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()), } } 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), } } #[cfg(test)] mod tests { use super::*; #[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, String::new())); assert_eq!( parse_line("#####"), GeminiLine::Heading(3, "##".to_string()) ); assert_eq!(parse_line("# "), GeminiLine::Heading(1, " ".to_string())); 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) ); } #[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(), String::new()) ); assert_eq!( parse_line("=>"), GeminiLine::Link(String::new(), String::new()) ); assert_eq!( parse_line("=> "), GeminiLine::Link(String::new(), String::new()) ); assert_eq!( parse_line("=> multiple spaces in text"), GeminiLine::Link("multiple".to_string(), "spaces in text".to_string()) ); assert_eq!( parse_preformatted_line("=> https://example.com Link text"), GeminiLine::Text("=> https://example.com Link text".to_string(), true) ); } #[test] fn test_list_items() { assert_eq!( parse_line("* List item"), GeminiLine::ListItem("List item".to_string()) ); assert_eq!(parse_line("* "), GeminiLine::ListItem(String::new())); 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()) ); } #[test] fn test_quotes() { assert_eq!( parse_line(">Quote text"), GeminiLine::Quote("Quote text".to_string()) ); assert_eq!(parse_line(">"), GeminiLine::Quote(String::new())); assert_eq!(parse_line("> "), GeminiLine::Quote(" ".to_string())); assert_eq!( parse_line(">>Nested"), GeminiLine::Quote(">Nested".to_string()) ); } #[test] fn test_preformatted() { assert_eq!( parse_line("```alt-text"), GeminiLine::PreformattedToggle(true, "alt-text".to_string()) ); assert_eq!( parse_line("```"), GeminiLine::PreformattedToggle(true, String::new()) ); 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, String::new()) ); assert_eq!( parse_preformatted_line("```"), GeminiLine::PreformattedToggle(false, String::new()) ); } #[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(String::new(), false)); assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); } #[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) ); } #[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, String::new()), ] ); } }