X-Git-Url: https://git.r.bdr.sh/rbdr/page/blobdiff_plain/8d4fac527ea33456de21933b4632a5bf4abbfc8d..2cbae13cfd94f48dfe9a8c903e05aea49106b778:/src/gemini_parser.rs diff --git a/src/gemini_parser.rs b/src/gemini_parser.rs index 36abb16..418b758 100644 --- a/src/gemini_parser.rs +++ b/src/gemini_parser.rs @@ -1,154 +1,222 @@ -pub fn parse(source: &str) -> String { - - let lines = source.split("\n"); - let mut is_preformatted = false; - - let mut html:String = "".to_owned(); - let mut current_line_type: Option = None; - - for line in lines { - let line_type = identify_line(&(line[..3]), is_preformatted); - match line_type { - LineType::PreformattedToggle => is_preformatted = !is_preformatted, - _ => { - // 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)); - } - } else { - html.push_str(get_line_opener(&line_type)); - } - - let line_content = get_partial_line_content(&line_type, line); - html.push_str(&line_content); - } else { - let line_content = get_full_line_content(&line_type, line); - html.push_str(&line_content); - } - 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 +#[derive(PartialEq, Eq, Debug)] +pub enum GeminiLine { + Text(String, bool), + PreformattedToggle(bool, String), + Heading(u8, String), + Link(String, String), + Quote(String), + ListItem(String) } -fn is_block(line_type: &LineType) -> bool { - return match line_type { - LineType::PreformattedText => true, - LineType::ListItem => true, - LineType::Quote => true, - _ => false, - } -} +/// 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. +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) + }; -fn get_partial_line_content(line_type: &LineType, line: &str) -> String { - return match line_type { - LineType::ListItem => format!("
  • {}
  • ", line[2..].trim()), - LineType::Quote => line[1..].trim().to_string(), - LineType::PreformattedText => format!("{}\n", line), - _ => "".to_string(), - } -} + let new_is_preformatted = match parsed { + GeminiLine::PreformattedToggle(x, _) => x, + _ => is_preformatted + }; -fn get_full_line_content(line_type: &LineType, line: &str) -> String { - match line_type { - LineType::Text => format!("

    {}

    \n", line.trim()), - LineType::Blank => "
    \n".to_string(), - LineType::Link => format!("
    {}
    \n", get_link_address(line), get_link_content(line)), - LineType::Heading1 => format!("

    {}

    \n", line[1..].trim()), - LineType::Heading2 => format!("

    {}

    \n", line[2..].trim()), - LineType::Heading3 => format!("

    {}

    \n", line[3..].trim()), - _ => "".to_string(), - } + lines.push(parsed); + (lines, new_is_preformatted) + } + ) + .0 } -fn get_line_opener(line_type: &LineType) -> &'static str { - match line_type { - LineType::ListItem => "
      ", - LineType::Quote => "
      ", - LineType::PreformattedText => "
      ",
      -        _ => "",
      +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_line_closer(line_type: &LineType) -> &'static str {
      -    match line_type {
      -        LineType::ListItem => "
    \n", - LineType::Quote => "\n", - LineType::PreformattedText => "\n", - _ => "", +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), } } -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() - } - components[0].trim() -} +#[cfg(test)] +mod tests { + use super::*; -fn get_link_address(line: &str) -> &str { - let components: Vec<&str> = line[2..].trim().splitn(2, " ").collect(); - components[0].trim() -} + #[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())); -fn identify_line(line: &str, is_preformatted: bool) -> LineType { - if line.starts_with("```") { - return LineType::PreformattedToggle; + 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)); } - 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; + + #[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()) + ); + + 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()) + ); + + assert_eq!( + parse_preformatted_line("=> https://example.com Link text"), + GeminiLine::Text("=> https://example.com Link text".to_string(), true) + ); } - if line.starts_with(">") { - return LineType::Quote; + + #[test] + fn test_list_items() { + assert_eq!( + parse_line("* List item"), + GeminiLine::ListItem("List item".to_string()) + ); + + 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())); } - if line.starts_with("###") { - return LineType::Heading3; + + #[test] + fn test_quotes() { + assert_eq!( + parse_line(">Quote text"), + GeminiLine::Quote("Quote text".to_string()) + ); + + 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()), + ]); + } }