use std::fmt::Display;
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Doc {
Text(String),
Link {
to: String,
name: Option<String>,
},
Heading(Level, String),
ListItem(String),
Quote(String),
Preformatted {
alt: Option<String>,
text: String,
},
Blank,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Level {
One,
Two,
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, "###"),
}
}
}
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct Builder {
docs: Vec<Doc>,
}
impl Builder {
pub fn new() -> Self {
Self::default()
}
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
}
pub fn text(self, text: impl Into<String>) -> Self {
self.push(Doc::Text(text.into()))
}
pub fn line(self) -> Self {
self.push(Doc::Blank)
}
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 })
}
pub fn link_unnamed(self, to: impl Into<String>) -> Self {
let to = to.into();
self.push(Doc::Link { to, name: None })
}
pub fn heading(self, level: Level, heading: impl Into<String>) -> Self {
self.push(Doc::Heading(level, heading.into()))
}
pub fn h1(self, heading: impl Into<String>) -> Self {
self.heading(Level::One, heading)
}
pub fn h2(self, heading: impl Into<String>) -> Self {
self.heading(Level::Two, heading)
}
pub fn h3(self, heading: impl Into<String>) -> Self {
self.heading(Level::Three, heading)
}
pub fn list_item(self, item: String) -> Self {
self.push(Doc::ListItem(item))
}
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)
}
pub fn quote(self, quote: impl Into<String>) -> Self {
self.push(Doc::Quote(quote.into()))
}
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 })
}
pub fn build(self) -> String {
format!("{}", self)
}
pub fn iter(&self) -> impl Iterator<Item = &Doc> {
self.docs.iter()
}
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
"#;
#[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)
}
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])
}
}
}