use {super::Node, std::fmt::Write}; /// An AST structure which contains an AST tree /// /// # Example /// /// ```rust /// let _ = germ::ast::Ast::from_string(r#"=> gemini://gem.rest/ GemRest"#); /// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct Ast { inner: Vec, } impl Ast { /// Build an AST tree from Gemtext /// /// # Example /// /// ```rust /// let _ = germ::ast::Ast::from_string(r#"=> gemini://gem.rest/ GemRest"#); /// ``` #[must_use] pub fn from_owned(value: &(impl AsRef + ?Sized)) -> Self { Self::from_value(value.as_ref()) } /// Build an AST tree from Gemtext /// /// # Example /// /// ```rust /// let _ = germ::ast::Ast::from_string(r#"=> gemini://gem.rest/ GemRest"#); /// ``` #[must_use] #[allow(clippy::needless_pass_by_value)] pub fn from_string(value: impl Into) -> Self { Self::from_value(&value.into()) } /// Build an AST tree from a value /// /// # Example /// /// ```rust /// let _ = germ::ast::Ast::from_value(r#"=> gemini://gem.rest/ GemRest"#); /// ``` #[must_use] pub fn from_value(value: &(impl ToString + ?Sized)) -> Self { let mut ast = vec![]; let mut in_preformatted = false; let mut in_list = false; let source = value.to_string(); let mut lines = source.lines(); // Iterate over all lines in the Gemtext `source` while let Some(line) = lines.next() { // Evaluate the Gemtext line and append its AST node to the `ast` tree ast.append(&mut Self::evaluate( line, &mut lines, &mut in_preformatted, &mut in_list, )); } if source.ends_with('\n') { if let Some(last) = ast.last() { if !matches!(last, Node::Whitespace) { ast.push(Node::Whitespace); } } } Self { inner: ast } } /// Build an AST tree from a [`Vec`] of [`Node`]s /// /// # Example /// /// ```rust /// // This assertion converts the Gemtext "=> / Home\n" to an AST tree of one /// // node, then converts the AST tree back to Gemtext, and compares it against /// // the original Gemtext. /// assert_eq!( /// germ::ast::Ast::from_nodes( /// germ::gemini_to_ast!("=> / Home").inner().to_vec() /// ) /// .to_gemtext(), /// "=> / Home" /// ); /// ``` #[must_use] pub const fn from_nodes(nodes: Vec) -> Self { Self { inner: nodes } } #[must_use] pub fn to_gemtext(&self) -> String { let mut gemtext = String::new(); for node in &self.inner { match node { Node::Text(text) => { let _ = writeln!(&mut gemtext, "{text}"); } Node::Link { to, text } => { let _ = writeln!( &mut gemtext, "=> {}{}", to, text.clone().map_or_else(String::new, |text| format!(" {text}")), ); } Node::Heading { level, text } => { let _ = writeln!(&mut gemtext, "{} {}", "#".repeat(*level), text); } Node::List(items) => { let _ = writeln!( &mut gemtext, "{}", items .iter() .map(|i| format!("* {i}")) .collect::>() .join("\n"), ); } Node::Blockquote(text) => { let _ = writeln!(&mut gemtext, "> {text}"); } Node::PreformattedText { alt_text, text } => { let _ = writeln!( &mut gemtext, "```{}\n{}```", alt_text.clone().unwrap_or_default(), text ); } Node::Whitespace => gemtext.push('\n'), } } if gemtext.ends_with('\n') { gemtext.pop(); } gemtext } /// The actual AST of `Ast` /// /// # Example /// /// ```rust /// let _ = /// germ::ast::Ast::from_string(r#"=> gemini://gem.rest/ GemRest"#).inner(); /// ``` #[must_use] pub const fn inner(&self) -> &Vec { &self.inner } #[allow(clippy::too_many_lines)] fn evaluate( line: &str, lines: &mut std::str::Lines<'_>, in_preformatted: &mut bool, in_list: &mut bool, ) -> Vec { let mut preformatted = String::new(); let mut alt_text = String::new(); let mut nodes = vec![]; let mut line = line; let mut list_items = vec![]; // Enter a not-so-infinite loop as sometimes, we may need to stay in an // evaluation loop, e.g., multiline contexts: preformatted text, lists, etc. loop { // Match the first character of the Gemtext line to understand the line // type match line.get(0..1).unwrap_or("") { "=" if !*in_preformatted => { // If the Gemtext line starts with an "=" ("=>"), it is a link line, // so splitting it up should be easy enough. let line = line.get(2..).unwrap_or(""); let mut split = line.split_whitespace(); nodes.push(Node::Link { to: split.next().unwrap_or_default().to_string(), text: { let rest: Vec<&str> = split.collect(); if rest.is_empty() { None } else { Some(rest.join(" ")) } }, }); break; } "#" if !*in_preformatted => { // If the Gemtext line starts with an "#", it is a heading, so let's // find out how deep it goes. let level = line.trim_start().chars().take_while(|&c| c == '#').count(); nodes.push(Node::Heading { level, // Here, the text after the heading markers is safely extracted. // `chars().skip()` is used to safely handle UTF-8 boundaries. text: line .chars() .skip(level) .collect::() .trim_start() .to_string(), }); break; } "*" if !*in_preformatted => { // If the Gemtext line starts with an asterisk, it is a list item, so // let's enter a list context. if !*in_list { *in_list = true; } list_items.push(line.get(1..).unwrap_or("").trim_start().to_string()); if let Some(next_line) = lines.next() { line = next_line; } else { break; } } ">" if !*in_preformatted => { // If the Gemtext line starts with an ">", it is a blockquote, so // let's just clip off the line identifier. nodes.push(Node::Blockquote( line.get(1..).unwrap_or("").trim_start().to_string(), )); break; } "`" => { // If the Gemtext line starts with a backtick, it's a preformatted // toggle, so let's enter a preformatted text context. *in_preformatted = !*in_preformatted; if *in_preformatted { alt_text = line.get(3..).unwrap_or("").to_string(); if let Some(next_line) = lines.next() { line = next_line; } else { break; } } else { nodes.push(Node::PreformattedText { alt_text: if alt_text.is_empty() { None } else { Some(alt_text) }, text: preformatted, }); break; } } "" if !*in_preformatted => { if line.is_empty() { // If the line has nothing on it, it is a whitespace line, as long // as we aren't in a preformatted line context. nodes.push(Node::Whitespace); } else { nodes.push(Node::Text(line.to_string())); } break; } // This as a catchall. It does a number of things. _ => { if *in_preformatted { // If we are in a preformatted line context, add the line to the // preformatted blocks content and increment the line. let _ = writeln!(&mut preformatted, "{line}"); if let Some(next_line) = lines.next() { line = next_line; } else { break; } } else { // If we are in a list item and hit a catchall, that must mean that // we encountered a line which is not a list line, so // let's stop adding items to the list context. if *in_list { *in_list = false; nodes.push(Node::Text(line.to_string())); break; } nodes.push(Node::Text(line.to_string())); break; } } } } if !list_items.is_empty() { nodes.reverse(); nodes.push(Node::List(list_items)); nodes.reverse(); } nodes } }