projects involving the gemini protocol
//! Gemtext documents
//!
//! The Gemini specification lays out a lightweight document format to
//! facilitate highly readable and sufficiently interactive content.
//!
//! Presently, this module only provides a way to incrementally build up a
//! gemtext document programmatically, and serialize as utf-8 text using any
//! type that implements `std::fmt::Write`.
//!
//! NB: Parsing gemtext from strings won't necessarily preserve whitespace.
//!
//! ```
//! use gemini::Builder;
//!
//! let doc = Builder::new().text("foo").line().quote("bar");
//! assert_eq!(doc.build(), "foo\n\n> bar\n");
//! ```

use std::fmt::Display;

// TODO: track "trivia"? eg spacing between sigils and content, trailing whitespace

/// Representation of lines in a gemtext document.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Doc {
    /// Text line, corresponding to a paragraph of plain text.
    Text(String),
    /// Link line, with an optional descriptive name.
    Link {
        /// Link target.
        to: String,
        /// Optional name for the link.
        name: Option<String>,
    },
    /// A heading to add structure to a document.
    Heading(Level, String),
    /// An individual list item. Repeated items will form a single list.
    ListItem(String),
    /// Block quote.
    Quote(String),
    /// Preformatted text. This is the only document that should contain line breaks.
    Preformatted {
        /// Optional alt text following the opening "```".
        alt: Option<String>,
        /// Preformatted text to display to clients.
        text: String,
    },
    /// Blank line. Technically equivalent to a Text line, but we use a sentinel to avoid allocation.
    Blank,
}

/// Heading level.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Level {
    /// H1, top level headings.
    One,
    /// H2, section headings.
    Two,
    /// H3, subsection headings.
    Three,
}

impl Display for Level {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Level::One => write!(f, "#"),
            Level::Two => write!(f, "##"),
            Level::Three => write!(f, "###"),
        }
    }
}

/// A type for incrementally building up a gemtext document that can then be
/// reified into text format.
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct Builder {
    docs: Vec<Doc>,
}

impl Builder {
    /// Create a new builder, representing the empty document.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a new builder from a given set of lines.
    pub fn from_docs(docs: Vec<Doc>) -> Self {
        Self { docs }
    }

    fn push(mut self, doc: Doc) -> Self {
        self.docs.push(doc);
        self
    }

    fn extend(mut self, docs: impl Iterator<Item = Doc>) -> Self {
        self.docs.extend(docs);
        self
    }

    /// Add a text line to the document.
    pub fn text(self, text: impl Into<String>) -> Self {
        self.push(Doc::Text(text.into()))
    }

    /// Add a blank line to the document.
    pub fn line(self) -> Self {
        self.push(Doc::Blank)
    }

    /// Add a link line to the document.
    pub fn link(self, to: impl Into<String>, name: Option<impl Into<String>>) -> Self {
        let to = to.into();
        let name = name.map(Into::into);
        self.push(Doc::Link { to, name })
    }

    /// Add a link line without a name to the document.
    pub fn link_unnamed(self, to: impl Into<String>) -> Self {
        let to = to.into();
        self.push(Doc::Link { to, name: None })
    }

    /// Add a heading of any level to the document.
    pub fn heading(self, level: Level, heading: impl Into<String>) -> Self {
        self.push(Doc::Heading(level, heading.into()))
    }

    /// Add a top level heading to the document.
    pub fn h1(self, heading: impl Into<String>) -> Self {
        self.heading(Level::One, heading)
    }

    /// Add a section heading to the document.
    pub fn h2(self, heading: impl Into<String>) -> Self {
        self.heading(Level::Two, heading)
    }

    /// Add a subsection heading to the document.
    pub fn h3(self, heading: impl Into<String>) -> Self {
        self.heading(Level::Three, heading)
    }

    /// Add an individual list item to the document.
    pub fn list_item(self, item: String) -> Self {
        self.push(Doc::ListItem(item))
    }

    /// Add a list to the document, from an iterator of strings.
    pub fn list<S: Into<String>>(self, items: impl IntoIterator<Item = S>) -> Self {
        let items = items.into_iter().map(|s| Doc::ListItem(s.into()));
        self.extend(items)
    }

    /// Add a block quote to the document.
    pub fn quote(self, quote: impl Into<String>) -> Self {
        self.push(Doc::Quote(quote.into()))
    }

    /// Add some preformatted text to the document.
    pub fn preformatted(self, alt: Option<impl Into<String>>, text: impl Into<String>) -> Self {
        let alt = alt.map(Into::into);
        let text = text.into();
        self.push(Doc::Preformatted { alt, text })
    }

    /// Generate a string representation of a document.
    pub fn build(self) -> String {
        format!("{}", self)
    }

    /// An iterator over references to the documents in the builder.
    pub fn iter(&self) -> impl Iterator<Item = &Doc> {
        self.docs.iter()
    }

    /// An iterator over mutable references to the documents in the builder.
    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Doc> {
        self.docs.iter_mut()
    }
}

impl Display for Builder {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.docs.iter().try_for_each(|doc| match doc {
            Doc::Text(t) => {
                writeln!(f, "{}", t)
            }
            Doc::Link { to, name } => {
                write!(f, "=> {}", to)?;
                if let Some(name) = name {
                    write!(f, " {}", name)?;
                }
                writeln!(f)
            }
            Doc::Heading(lvl, h) => {
                writeln!(f, "{} {}", lvl, h)
            }
            Doc::ListItem(i) => {
                writeln!(f, "* {}", i)
            }
            Doc::Quote(q) => {
                writeln!(f, "> {}", q)
            }
            Doc::Preformatted { alt, text } => {
                writeln!(
                    f,
                    "```{}\n{}\n```",
                    alt.as_ref().unwrap_or(&String::new()),
                    text
                )
            }
            Doc::Blank => writeln!(f),
        })
    }
}

impl IntoIterator for Builder {
    type Item = Doc;

    type IntoIter = std::vec::IntoIter<Doc>;

    fn into_iter(self) -> Self::IntoIter {
        self.docs.into_iter()
    }
}

#[cfg(test)]
const DOCUMENT: &str = r#"
```logo
 wooo
/^^^^\
|    |
\____/
```

# GAZE INTO THE SPHERE!

critics are raving
> i love the sphere - bort
> the sphere gives me purpose - frelvin

* always
* trust
* the sphere
=> gemini://sphere.gaze gaze more here
"#;

/// Parser
#[cfg(feature = "parsers")]
pub mod parse {
    use nom::{
        branch::alt,
        bytes::complete::{tag, take_until},
        character::complete::{line_ending, not_line_ending},
        combinator::{map, opt, value},
        error::context,
        multi::separated_list0,
        sequence::{delimited, pair, preceded, terminated},
        IResult,
    };

    use super::*;

    fn text(input: &str) -> IResult<&str, Doc> {
        context(
            "gemtext text line",
            map(not_line_ending, |s: &str| {
                if s.is_empty() {
                    Doc::Blank
                } else {
                    Doc::Text(s.to_string())
                }
            }),
        )(input)
    }

    fn link(input: &str) -> IResult<&str, Doc> {
        context(
            "gemtext link line",
            map(preceded(tag("=>"), not_line_ending), |s: &str| {
                let s = s.trim();
                let idx = s.chars().position(char::is_whitespace);
                let (to, name) = if let Some(idx) = idx {
                    let (to, name) = s.split_at(idx);
                    (to.to_string(), Some(name.trim().to_string()))
                } else {
                    (s.to_string(), None)
                };
                Doc::Link { to, name }
            }),
        )(input)
    }

    fn level(input: &str) -> IResult<&str, Level> {
        alt((
            value(Level::Three, tag("###")),
            value(Level::Two, tag("##")),
            value(Level::One, tag("#")),
        ))(input)
    }

    fn heading(input: &str) -> IResult<&str, Doc> {
        context(
            "gemtext heading line",
            map(pair(level, not_line_ending), |(lvl, s)| {
                Doc::Heading(lvl, s.trim().to_string())
            }),
        )(input)
    }

    fn list_item(input: &str) -> IResult<&str, Doc> {
        context(
            "gemtex list item line",
            map(preceded(tag("* "), not_line_ending), |s: &str| {
                Doc::ListItem(s.trim().to_string())
            }),
        )(input)
    }

    fn quote(input: &str) -> IResult<&str, Doc> {
        context(
            "gemtext quote line",
            map(preceded(tag(">"), not_line_ending), |s: &str| {
                Doc::Quote(s.trim().to_string())
            }),
        )(input)
    }

    fn preformatted(input: &str) -> IResult<&str, Doc> {
        context(
            "gemtext preformatted block",
            map(
                terminated(
                    pair(
                        delimited(tag("```"), opt(not_line_ending), line_ending),
                        take_until("\n```"),
                    ),
                    pair(line_ending, tag("```")),
                ),
                |(alt, text): (Option<&str>, &str)| Doc::Preformatted {
                    alt: alt.and_then(|s| {
                        let s = s.trim();
                        if s.is_empty() {
                            None
                        } else {
                            Some(s.to_string())
                        }
                    }),
                    text: text.to_string(),
                },
            ),
        )(input)
    }

    /// Parse a *complete* gemtext document from utf-8 encoded text.
    pub fn document(input: &str) -> IResult<&str, Builder> {
        context(
            "gemtext document",
            map(
                terminated(
                    separated_list0(
                        line_ending,
                        alt((link, heading, list_item, quote, preformatted, text)),
                    ),
                    opt(line_ending),
                ),
                Builder::from_docs,
            ),
        )(input)
    }

    #[cfg(test)]
    mod test {
        use super::*;

        trait Is {
            type Item;
            fn is(&self, other: Self::Item) -> bool;
        }

        impl<T: Eq, U, E> Is for Result<(U, T), E> {
            type Item = T;

            fn is(&self, other: Self::Item) -> bool {
                if let Ok((_, inner)) = self {
                    inner == &other
                } else {
                    false
                }
            }
        }

        macro_rules! assert_is {
            ($actual:expr, $expected:expr) => {
                assert!(
                    $actual.is($expected),
                    "\n\nexpected: {:#?}\n\nactual: {:#?}",
                    $expected,
                    $actual
                )
            };
        }

        #[test]
        fn test_text() {
            assert_is!(text("foo"), Doc::Text("foo".to_string()));
            assert_is!(text("\nfoo"), Doc::Blank);
        }

        #[test]
        fn test_link() {
            assert_is!(
                link("=> gemini://foo"),
                Doc::Link {
                    to: "gemini://foo".to_string(),
                    name: None
                }
            );
            assert_is!(
                link("=> gemini://foo bar"),
                Doc::Link {
                    to: "gemini://foo".to_string(),
                    name: Some("bar".to_string()),
                }
            );
            assert_is!(
                link("=>  gemini://foo    bar baz bax"),
                Doc::Link {
                    to: "gemini://foo".to_string(),
                    name: Some("bar baz bax".to_string())
                }
            );
            assert_is!(
                link("=>gemini://foo"),
                Doc::Link {
                    to: "gemini://foo".to_string(),
                    name: None
                }
            )
        }

        #[test]
        fn test_heading() {
            assert_is!(
                heading("# foo"),
                Doc::Heading(Level::One, "foo".to_string())
            );
            assert_is!(
                heading("##  bar"),
                Doc::Heading(Level::Two, "bar".to_string())
            );
            assert_is!(
                heading("###baz   "),
                Doc::Heading(Level::Three, "baz".to_string())
            )
        }

        #[test]
        fn test_list_item() {
            assert_is!(list_item("* foo"), Doc::ListItem("foo".to_string()));
            assert_is!(list_item("*  bar"), Doc::ListItem("bar".to_string()));
            assert!(list_item("*bad").is_err())
        }

        #[test]
        fn test_quote() {
            assert_is!(quote("> foo"), Doc::Quote("foo".to_string()));
            assert_is!(quote(">bar"), Doc::Quote("bar".to_string()));
        }

        #[test]
        fn test_preformatted() {
            assert_is!(
                preformatted("```\nfoo\n```"),
                Doc::Preformatted {
                    alt: None,
                    text: "foo".to_string()
                }
            );
            assert_is!(
                preformatted("```\n\nfoo\n> bar\n=> baz\n```\n"),
                Doc::Preformatted {
                    alt: None,
                    text: "\nfoo\n> bar\n=> baz".to_string()
                }
            );
            assert_is!(
                preformatted("```foo\nbar\n```"),
                Doc::Preformatted {
                    alt: Some("foo".to_string()),
                    text: "bar".to_string()
                }
            );
            assert_is!(
                preformatted("```   foo   \nbar\n```"),
                Doc::Preformatted {
                    alt: Some("foo".to_string()),
                    text: "bar".to_string()
                }
            );
            assert!(preformatted("```\n").is_err());
            assert!(preformatted("```\nfoo```").is_err());
        }

        #[test]
        fn test_document() {
            assert_is!(
                document(DOCUMENT),
                Builder::from_docs(vec![
                    Doc::Blank,
                    Doc::Preformatted {
                        alt: Some("logo".to_string()),
                        text: " wooo\n/^^^^\\\n|    |\n\\____/".to_string()
                    },
                    Doc::Blank,
                    Doc::Heading(Level::One, "GAZE INTO THE SPHERE!".to_string()),
                    Doc::Blank,
                    Doc::Text("critics are raving".to_string()),
                    Doc::Quote("i love the sphere - bort".to_string()),
                    Doc::Quote("the sphere gives me purpose - frelvin".to_string()),
                    Doc::Blank,
                    Doc::ListItem("always".to_string()),
                    Doc::ListItem("trust".to_string()),
                    Doc::ListItem("the sphere".to_string()),
                    Doc::Link {
                        to: "gemini://sphere.gaze".to_string(),
                        name: Some("gaze more here".to_string())
                    },
                    Doc::Blank
                ])
            )
        }
    }
}

#[cfg(test)]
mod test {
    use super::*;
    #[test]
    fn test_builder() {
        let doc = Builder::new()
            .line()
            .preformatted(Some("logo"), " wooo\n/^^^^\\\n|    |\n\\____/")
            .line()
            .h1("GAZE INTO THE SPHERE!")
            .line()
            .text("critics are raving")
            .quote("i love the sphere - bort")
            .quote("the sphere gives me purpose - frelvin")
            .line()
            .list(vec!["always", "trust", "the sphere"])
            .link("gemini://sphere.gaze", Some("gaze more here"));
        assert_eq!(doc.build(), DOCUMENT)
    }

    #[test]
    fn test_iter() {
        let expected = [
            Doc::Blank,
            Doc::Heading(Level::One, "Wow!".to_string()),
            Doc::Quote("Wee!".to_string()),
            Doc::Link {
                to: "gemini://foo.bar".to_string(),
                name: None,
            },
        ];
        let doc = Builder::new()
            .line()
            .h1("Wow!")
            .quote("Wee!")
            .link_unnamed("gemini://foo.bar");
        for (i, actual) in doc.iter().enumerate() {
            assert_eq!(actual, &expected[i])
        }
    }
}