Something went wrong. Try again.
🦠 The Definitive Gemini Protocol Toolkit
gemini gemini-protocol gemtext parser zero-dependency toolkit ast converter html markdown cli networking
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315use {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<Node>,}
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<str> + ?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<String>) -> 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<Node>) -> 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::<Vec<String>>() .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<Node> { &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<Node> { 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::<String>() .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 }}