germ 0.3.7

The Ultimate Gemini Toolkit.
Documentation
// This file is part of Germ <https://github.com/gemrest/germ>.
// Copyright (C) 2022-2022 Fuwn <contact@fuwn.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// Copyright (C) 2022-2022 Fuwn <contact@fuwn.me>
// SPDX-License-Identifier: GPL-3.0-only

use super::Node;

/// An AST structure which contains an AST tree
///
/// # Example
///
/// ```rust
/// let _ = germ::ast::Ast::from_string(r#"=> gemini://gem.rest/ GemRest"#);
/// ```
#[derive(Clone)]
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_string(source: &str) -> Self {
    let mut ast = vec![];
    let mut in_preformatted = false;
    let mut in_list = false;
    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,
      ));
    }

    Self {
      inner: ast
    }
  }

  #[must_use]
  pub fn to_gemtext(&self) -> String {
    let mut gemtext = "".to_string();

    for node in &self.inner {
      match node {
        Node::Text(text) => gemtext.push_str(&format!("{}\n", text)),
        Node::Link {
          to,
          text,
        } =>
          gemtext.push_str(&format!(
            "=> {}{}\n",
            to,
            text
              .clone()
              .map_or_else(|| "".to_string(), |text| format!(" {}", text)),
          )),
        Node::Heading {
          level,
          text,
        } =>
          gemtext.push_str(&format!(
            "{} {}\n",
            match level {
              1 => "#",
              2 => "##",
              3 => "###",
              _ => "",
            },
            text
          )),
        Node::List(items) =>
          gemtext.push_str(&format!(
            "{}\n",
            items
              .iter()
              .map(|i| format!("* {}", i))
              .collect::<Vec<String>>()
              .join("\n"),
          )),
        Node::Blockquote(text) => gemtext.push_str(&format!("> {}\n", text)),
        Node::PreformattedText {
          alt_text,
          text,
        } =>
          gemtext.push_str(&format!(
            "```{}\n{}```\n",
            alt_text.clone().unwrap_or_else(|| "".to_string()),
            text
          )),
        Node::Whitespace => gemtext.push('\n'),
      }
    }

    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 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();
          let mut split = line
            .split_whitespace()
            .map(String::from)
            .collect::<Vec<String>>()
            .into_iter();

          nodes.push(Node::Link {
            to:   split.next().expect("no location in link"),
            text: {
              let rest = split.collect::<Vec<String>>().join(" ");

              if rest.is_empty() {
                None
              } else {
                Some(rest)
              }
            },
          });

          break;
        }
        "#" => {
          // If the Gemtext line starts with an "#", it is a heading, so let's
          // find out how deep it goes.
          let level = match line.get(0..3) {
            Some(root) =>
              if root.contains("###") {
                3
              } else if root.contains("##") {
                2
              } else if root.contains('#') {
                1
              } else {
                0
              },
            None => 0,
          };

          nodes.push(Node::Heading {
            level,
            // Here, we are `get`ing the `&str` starting at the `level`-th
            // index, then trimming the start. These operations
            // effectively off the line identifier.
            text: line.get(level..).unwrap_or("").trim_start().to_string(),
          });

          break;
        }
        "*" => {
          // 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 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 is a list item, 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 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);

          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.
            preformatted.push_str(&format!("{}\n", 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
  }
}