~autumnull/flatiron

3f6bd7fb0f9c23b981d63969a94a18c19dd19231 — Autumn! 2 years ago a4c8022
Refactored parser into submodules
19 files changed, 2634 insertions(+), 2482 deletions(-)

R tests/sample.textile => samples/textism.textile
M src/lib.rs
D src/parse.rs
R tests/acronym.rs => src/parse/acronym.rs
A src/parse/attributes.rs
A src/parse/block.rs
R tests/image.rs => src/parse/image.rs
A src/parse/link.rs
A src/parse/list.rs
R tests/textile.rs => src/parse/mod.rs
A src/parse/no_textile.rs
A src/parse/phrase.rs
A src/parse/table.rs
A src/structs.rs
D tests/attributes.rs
D tests/block.rs
D tests/link.rs
D tests/notextile.rs
D tests/phrase.rs
R tests/sample.textile => samples/textism.textile +0 -0
M src/lib.rs => src/lib.rs +5 -2
@@ 1,2 1,5 @@
pub mod parse;
pub mod render;
#![allow(dead_code)]

mod parse;
mod render;
mod structs;

D src/parse.rs => src/parse.rs +0 -1037
@@ 1,1037 0,0 @@
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, tag, take_while1, take_while_m_n},
    character::complete::{char, line_ending, none_of, satisfy},
    combinator::{complete, eof, fail, map, map_res, opt, value},
    multi::{
        fold_many0, fold_many1, fold_many_m_n, many0, many0_count, many1_count,
        many_till,
    },
    sequence::{delimited, preceded, terminated, tuple},
    IResult,
};

#[derive(Debug, PartialEq)]
pub struct Textile(pub Vec<BlockTag>);

#[derive(Debug, PartialEq)]
pub enum BlockTag {
    Basic {
        kind: BlockKind,
        indent: Option<Indent>,
        align: Option<Align>,
        attributes: Option<Attributes>,
        content: InlineTag,
    },
    Preformatted {
        kind: BlockKind,
        indent: Option<Indent>,
        align: Option<Align>,
        attributes: Option<Attributes>,
        content: String,
    },
    List {
        indent: Option<Indent>,
        align: Option<Align>,
        attributes: Option<Attributes>,
        content: List,
    },
    NoTextile(String),
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum BlockKind {
    Paragraph,
    NoTextile,
    BlockQuote,
    BlockCode,
    Preformatted,
    Header(usize),
    Footnote(usize),
}

#[derive(Debug, PartialEq)]
pub struct List {
    pub kind: ListKind,
    pub items: Vec<ListItem>,
}

#[derive(Debug, PartialEq)]
pub struct ListItem {
    pub content: InlineTag,
    pub sublist: Option<List>,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum ListKind {
    Numeric,
    Bulleted,
}

#[derive(Debug, PartialEq)]
pub struct Indent {
    pub left: usize,
    pub right: usize,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Align {
    Left,
    Right,
    Center,
    Justify,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum VerticalAlign {
    Top,
    Middle,
    Bottom,
}

#[derive(Debug, PartialEq)]
pub struct Attributes {
    pub class: Option<String>,
    pub id: Option<String>,
    pub style: Option<String>,
    pub language: Option<String>,
}

#[derive(Debug, PartialEq)]
pub enum InlineTag {
    Plaintext(String),
    NoTextile(String),
    Code(String),
    FootnoteRef(usize),
    LineBreak,
    Image {
        attributes: Option<Attributes>,
        align: Option<Align>,
        url: String,
        alt: Option<String>,
    },
    Acronym {
        title: Option<String>,
        content: String,
    },
    Link {
        attributes: Option<Attributes>,
        title: Option<String>,
        url: String,
        content: Box<InlineTag>,
    },
    Phrase {
        kind: Option<PhraseKind>,
        attributes: Option<Attributes>,
        content: Vec<InlineTag>,
    },
}

impl InlineTag {
    fn parsers() -> &'static [for<'r> fn(
        &'r str,
    ) -> Result<
        (&'r str, InlineTag),
        nom::Err<nom::error::Error<&'r str>>,
    >] {
        &[line_break, no_textile, code, footnote_ref, link, image]
    }
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum PhraseKind {
    Italic,
    Bold,
    Emphasis,
    Strong,
    Citation,
    Deleted,
    Inserted,
    Superscript,
    Subscript,
    Span,
}

impl PhraseKind {
    fn list() -> &'static [PhraseKind] {
        use PhraseKind::*;
        &[
            Italic,
            Bold,
            Emphasis,
            Strong,
            Citation,
            Deleted,
            Inserted,
            Superscript,
            Subscript,
            Span,
        ]
    }

    fn delimiter(self, input: &str) -> IResult<&str, &str> {
        use PhraseKind::*;
        match self {
            Italic => tag("__")(input),
            Bold => tag("**")(input),
            Emphasis => tag("_")(input),
            Strong => tag("*")(input),
            Citation => tag("??")(input),
            Deleted => tag("#")(input),
            Inserted => tag("+")(input),
            Superscript => tag("^")(input),
            Subscript => tag("~")(input),
            Span => tag("%")(input),
        }
    }
}

pub fn textile(input: &str) -> IResult<&str, Textile> {
    let (rest, blocks) = preceded(
        many0(line_ending),
        many0(terminated(block, many0(line_ending))),
    )(input)?;
    Ok((rest, Textile(blocks)))
}

pub fn block(input: &str) -> IResult<&str, BlockTag> {
    if input.is_empty() {
        return fail(input);
    }

    if let Ok((rest, list)) = list(input) {
        return Ok((rest, list));
    }

    let (rest, opt_header) = opt(block_header)(input)?;
    let (kind, attributes, opt_indent_align, extended) =
        opt_header.unwrap_or((BlockKind::Paragraph, None, None, false));
    let (indent, align) = opt_indent_align.unwrap_or((None, None));

    // get body of block to be parsed later.
    let mut i = 0;
    loop {
        match if extended {
            alt((eof, preceded(end_of_block, value("", block_header))))(
                &rest[i..],
            )
        } else {
            end_of_block(&rest[i..])
        } {
            Ok(_) => {
                break;
            }
            _ => {
                i += 1;
            }
        }
    }

    let body = &rest[..i];
    // consume interstitial newlines if they are there
    let (rest, _) = alt((eof, end_of_block))(&rest[i..])?;

    match kind {
        BlockKind::Paragraph
        | BlockKind::Header(_)
        | BlockKind::Footnote(_)
        | BlockKind::BlockQuote => {
            let (_, content) = complete(phrase)(body)?;
            Ok((
                rest,
                BlockTag::Basic {
                    kind,
                    indent,
                    align,
                    attributes,
                    content,
                },
            ))
        }
        BlockKind::BlockCode | BlockKind::Preformatted => {
            let mut content = String::from(body);
            if let Ok((_, stripped)) = complete(strip_flatiron_extended)(body) {
                content = stripped;
            }
            Ok((
                rest,
                BlockTag::Preformatted {
                    kind,
                    indent,
                    align,
                    attributes,
                    content,
                },
            ))
        }
        BlockKind::NoTextile => {
            let mut content = String::from(body);
            if let Ok((_, stripped)) = complete(strip_flatiron_extended)(body) {
                content = stripped;
            }
            Ok((rest, BlockTag::NoTextile(content)))
        }
    }
}

fn list(input: &str) -> IResult<&str, BlockTag> {
    let (rest, (kind, attributes, opt_indent_align)) = terminated(
        tuple((
            alt((
                value(ListKind::Numeric, char('#')),
                value(ListKind::Bulleted, char('*')),
            )),
            opt(attributes),
            opt(indent_align),
        )),
        char(' '),
    )(input)?;
    let (indent, align) = opt_indent_align.unwrap_or((None, None));

    let mut all_items: Vec<(ListKind, usize, InlineTag)> = Vec::new();
    // get first item
    let (rest, first_content) = list_item_content(rest)?;
    all_items.push((kind, 1, first_content));

    // collect all items with depth information
    let mut input = rest;
    while let Err(_) = end_of_block(input) {
        let (rest, (item_kind, depth)) = list_item_head(input)?;
        let (rest, content) = list_item_content(rest)?;
        all_items.push((item_kind, depth, content));
        input = rest;
    }

    // convert flat items list to nested list
    match list_items_to_nested_list(&mut all_items) {
        Ok(l) => Ok((
            input,
            BlockTag::List {
                indent,
                align,
                attributes,
                content: l,
            },
        )),
        Err(_) => fail(input),
    }
}

fn list_item_head(input: &str) -> IResult<&str, (ListKind, usize)> {
    delimited(
        line_ending,
        alt((
            map(many1_count(char('#')), |n| (ListKind::Numeric, n)),
            map(many1_count(char('*')), |n| (ListKind::Bulleted, n)),
        )),
        char(' '),
    )(input)
}

fn list_item_content(input: &str) -> IResult<&str, InlineTag> {
    let mut i = 0;
    while let Err(_) = end_list_item(&input[i..]) {
        i += 1
    }
    let (_, content) = phrase(&input[..i])?;
    Ok((&input[i..], content))
}

fn end_list_item(input: &str) -> IResult<&str, &str> {
    alt((value("", list_item_head), end_of_block))(input)
}

fn list_items_to_nested_list(
    list: &mut Vec<(ListKind, usize, InlineTag)>,
) -> Result<List, String> {
    let mut items: Vec<ListItem> = Vec::new();
    let kind = list[0].0;
    let depth = list[0].1;
    while !list.is_empty() {
        if list[0].1 > depth {
            // unwrap should be safe because depth can only be greater after 1st item
            items.last_mut().unwrap().sublist =
                Some(list_items_to_nested_list(list)?);
        } else if list[0].1 < depth {
            break;
        } else if list[0].0 != kind {
            return Err(format!(
                "List uses mixed markers on same level: {:?}",
                list[0].2
            ));
        } else {
            let (_, _, content) = list.remove(0);
            items.push(ListItem {
                content,
                sublist: None,
            })
        }
    }
    Ok(List { kind, items })
}

fn block_header(
    input: &str,
) -> IResult<
    &str,
    (
        BlockKind,
        Option<Attributes>,
        Option<(Option<Indent>, Option<Align>)>,
        bool,
    ),
> {
    tuple((
        block_kind,
        opt(attributes),
        opt(indent_align),
        alt((value(true, tag(".. ")), value(false, tag(". ")))), // is extended
    ))(input)
}

fn block_kind(input: &str) -> IResult<&str, BlockKind> {
    alt((
        value(BlockKind::Preformatted, tag("pre")),
        value(BlockKind::BlockCode, tag("bc")),
        value(BlockKind::BlockQuote, tag("bq")),
        value(BlockKind::Paragraph, tag("p")),
        value(BlockKind::NoTextile, tag("notextile")),
        header_modifier,
        footnote_modifier,
    ))(input)
}

fn header_modifier(input: &str) -> IResult<&str, BlockKind> {
    let (rest, n) = preceded(
        char('h'),
        alt((
            value(1, char('1')),
            value(2, char('2')),
            value(3, char('3')),
            value(4, char('4')),
            value(5, char('5')),
            value(6, char('6')),
        )),
    )(input)?;
    Ok((rest, BlockKind::Header(n)))
}

fn footnote_modifier(input: &str) -> IResult<&str, BlockKind> {
    let (rest, n) = preceded(
        tag("fn"),
        map_res(take_while1(|c: char| c.is_ascii_digit()), from_num),
    )(input)?;
    Ok((rest, BlockKind::Footnote(n)))
}

fn from_num(input: &str) -> Result<usize, std::num::ParseIntError> {
    usize::from_str_radix(input, 10)
}

fn indent_align(
    mut input: &str,
) -> IResult<&str, (Option<Indent>, Option<Align>)> {
    let (mut opt_indent, mut opt_align) = (None, None);
    loop {
        if let Ok((rest, i)) = indent(input) {
            if opt_indent.is_none() {
                opt_indent = Some(i);
                input = rest;
            } else {
                return Ok((input, (opt_indent, opt_align)));
            }
        } else if let Ok((rest, a)) = alignment(input) {
            if opt_align.is_none() {
                opt_align = Some(a);
                input = rest;
            } else {
                return Ok((input, (opt_indent, opt_align)));
            }
        } else {
            break;
        }
    }

    if opt_indent.is_some() || opt_align.is_some() {
        Ok((input, (opt_indent, opt_align)))
    } else {
        fail(input)
    }
}

fn indent(input: &str) -> IResult<&str, Indent> {
    let (rest, (left, right)) =
        tuple((many0_count(char('(')), many0_count(char(')'))))(input)?;
    if left + right > 0 {
        Ok((rest, Indent { left, right }))
    } else {
        fail(input)
    }
}

fn alignment(input: &str) -> IResult<&str, Align> {
    alt((
        value(Align::Justify, tag("<>")),
        value(Align::Left, tag("<")),
        value(Align::Right, tag(">")),
        value(Align::Center, tag("=")),
    ))(input)
}

pub fn attributes(mut input: &str) -> IResult<&str, Attributes> {
    let mut attributes = Attributes {
        class: None,
        id: None,
        style: None,
        language: None,
    };
    loop {
        if let Ok((rest, (class, id))) = class_and_id(input) {
            if attributes.class.is_none() {
                attributes.class = class;
            } else if class.is_some() {
                return Ok((input, attributes));
            }
            if attributes.id.is_none() {
                attributes.id = id;
            } else if id.is_some() {
                return Ok((input, attributes));
            }
            input = rest;
        } else if let Ok((rest, style)) = style(input) {
            if attributes.style.is_none() {
                attributes.style = Some(style);
            } else {
                return Ok((input, attributes));
            }
            input = rest;
        } else if let Ok((rest, language)) = language(input) {
            if attributes.language.is_none() {
                attributes.language = Some(language);
            } else {
                return Ok((input, attributes));
            }
            input = rest;
        } else {
            break;
        }
    }
    if attributes.class.is_some()
        || attributes.id.is_some()
        || attributes.style.is_some()
        || attributes.language.is_some()
    {
        Ok((input, attributes))
    } else {
        fail(input)
    }
}

fn class_and_id(
    input: &str,
) -> IResult<&str, (Option<String>, Option<String>)> {
    let (rest, (opt_class, opt_id)) = delimited(
        char('('),
        tuple((opt(class_name), opt(preceded(char('#'), class_name)))),
        char(')'),
    )(input)?;
    if opt_class.is_some() || opt_id.is_some() {
        Ok((rest, (opt_class, opt_id)))
    } else {
        fail(rest)
    }
}

fn class_name(input: &str) -> IResult<&str, String> {
    escaped_transform(
        none_of("\\#()"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
            value("#", tag("#")),
        )),
    )(input)
}

fn style(input: &str) -> IResult<&str, String> {
    delimited(
        char('{'),
        escaped_transform(
            none_of("\\{}"),
            '\\',
            alt((
                value("\\", tag("\\")),
                value("{", tag("{")),
                value("}", tag("}")),
            )),
        ),
        char('}'),
    )(input)
}

fn language(input: &str) -> IResult<&str, String> {
    delimited(
        char('['),
        escaped_transform(
            none_of("\\[]"),
            '\\',
            alt((
                value("\\", tag("\\")),
                value("[", tag("[")),
                value("]", tag("]")),
            )),
        ),
        char(']'),
    )(input)
}

fn end_of_block(input: &str) -> IResult<&str, &str> {
    alt((
        eof,
        value("", tuple((line_ending, line_ending))),
        value("", tuple((line_ending, eof))),
    ))(input)
}

fn strip_flatiron_extended(input: &str) -> IResult<&str, String> {
    escaped_transform(none_of("\n"), '\n', value("\n", tag("|")))(input)
}

pub fn phrase(input: &str) -> IResult<&str, InlineTag> {
    let mut input_string = input.to_string();
    if let Ok((_, stripped)) = complete(strip_flatiron_extended)(input) {
        input_string = stripped;
    }
    let mut input = input_string.as_str();

    let mut content: Vec<InlineTag> = Vec::new();

    let mut i = 0;
    let mut preceding_space = true;
    loop {
        // check if end of phrase has been reached
        if &input[i..] == "" {
            if i > 0 {
                content.push(InlineTag::Plaintext(String::from(&input[..i])));
            }
            match content.len() {
                0 => {
                    // return empty plaintext rather than phrase
                    return Ok(("", InlineTag::Plaintext(String::from(""))));
                }
                1 => {
                    // return the only tag
                    return Ok(("", content.remove(0)));
                }
                _ => (),
            }
            return Ok((
                "",
                InlineTag::Phrase {
                    attributes: None,
                    kind: None,
                    content,
                },
            ));
        }

        let mut matched = None;
        if preceding_space {
            // only match acronym if preceded by a space
            if let Ok((rest, tag)) = acronym(&input[i..]) {
                matched = Some((rest, tag))
            }
        }
        if matched.is_none() {
            for parser in InlineTag::parsers() {
                if let Ok((rest, tag)) = parser(&input[i..]) {
                    matched = Some((rest, tag));
                    break;
                }
            }
        }
        if matched.is_none() {
            for phrase_kind in PhraseKind::list() {
                if let Ok((rest, tag)) =
                    tagged_phrase(*phrase_kind)(&input[i..])
                {
                    matched = Some((rest, tag));
                    break;
                }
            }
        }

        if let Some((rest, tag)) = matched {
            if i > 0 {
                content.push(InlineTag::Plaintext(String::from(&input[..i])));
            }
            input = rest;
            i = 0;
            content.push(tag);
            preceding_space = true;
        } else {
            let c = input.chars().nth(i).unwrap();
            preceding_space = !c.is_alphanumeric();
            i += 1;
        }
    }
}

fn tagged_phrase(
    kind: PhraseKind,
) -> impl FnMut(&str) -> IResult<&str, InlineTag> {
    move |mut input: &str| {
        // match opening delimiter
        let (rest, _delimiter) = kind.delimiter(input)?;
        input = rest;
        // only match attributes if phrase is tagged
        let (rest, attributes) = opt(attributes)(input)?;
        input = rest;

        let mut content: Vec<InlineTag> = Vec::new();

        let mut i = 0;
        let mut preceding_space = true;
        loop {
            if i >= input.len() {
                return fail(input);
            }
            // check if end of phrase has been reached
            if let Ok((rest, _delimiter)) = kind.delimiter(&input[i..]) {
                if i > 0 {
                    content
                        .push(InlineTag::Plaintext(String::from(&input[..i])));
                }
                return Ok((
                    rest,
                    InlineTag::Phrase {
                        attributes,
                        kind: Some(kind),
                        content,
                    },
                ));
            }

            let mut matched = None;
            if preceding_space {
                // only match acronym if preceded by a space
                if let Ok((rest, tag)) = acronym(&input[i..]) {
                    matched = Some((rest, tag))
                }
            }
            if matched.is_none() {
                for parser in InlineTag::parsers() {
                    if let Ok((rest, tag)) = parser(&input[i..]) {
                        matched = Some((rest, tag));
                        break;
                    }
                }
            }
            if matched.is_none() {
                for phrase_kind in PhraseKind::list() {
                    if let Ok((rest, tag)) =
                        tagged_phrase(*phrase_kind)(&input[i..])
                    {
                        matched = Some((rest, tag));
                        break;
                    }
                }
            }
            if let Some((rest, tag)) = matched {
                if i > 0 {
                    content
                        .push(InlineTag::Plaintext(String::from(&input[..i])));
                }
                input = rest;
                i = 0;
                content.push(tag);
                preceding_space = true;
            } else {
                let c = input.chars().nth(i).unwrap();
                preceding_space = !c.is_alphanumeric();
                i += 1;
            }
        }
    }
}

fn line_break(input: &str) -> IResult<&str, InlineTag> {
    let (rest, _) = line_ending(input)?;
    Ok((rest, InlineTag::LineBreak))
}

pub fn no_textile(input: &str) -> IResult<&str, InlineTag> {
    let (rest, content) = delimited(
        tag("=="),
        fold_many0(
            alt((
                value("\\", tag("\\\\")),
                value("==", tag("\\==")),
                no_textile_content,
            )),
            String::new,
            |mut acc, s| {
                acc.push_str(s);
                acc
            },
        ),
        tag("=="),
    )(input)?;
    Ok((rest, InlineTag::NoTextile(content)))
}

fn no_textile_content(input: &str) -> IResult<&str, &str> {
    let res: IResult<&str, &str> = alt((tag("=="), tag("\\")))(input);
    if let Err(_) = res {
        if input != "" {
            return Ok((&input[1..], &input[0..1]));
        }
    }
    fail(input)
}

fn code(input: &str) -> IResult<&str, InlineTag> {
    let (rest, content) = delimited(
        char('@'),
        escaped_transform(
            none_of("\\@"),
            '\\',
            alt((value("\\", char('\\')), value("@", char('@')))),
        ),
        char('@'),
    )(input)?;
    Ok((rest, InlineTag::Code(content)))
}

fn footnote_ref(input: &str) -> IResult<&str, InlineTag> {
    let (rest, n) = delimited(
        char('['),
        map_res(take_while1(|c: char| c.is_ascii_digit()), from_num),
        char(']'),
    )(input)?;
    Ok((rest, InlineTag::FootnoteRef(n)))
}

pub fn link(input: &str) -> IResult<&str, InlineTag> {
    let (rest, (attributes, (content_vec, (title, url)))) = preceded(
        char('"'),
        tuple((
            opt(attributes),
            many_till(
                alt((
                    value('\\', tag("\\\\")),
                    value('"', tag("\\\"")),
                    value('(', tag("\\(")),
                    value(')', tag("\\)")),
                    none_of("\\\"()"),
                )),
                tuple((
                    opt(delimited(
                        tag(" ("),
                        escaped_transform(
                            none_of("\\()"),
                            '\\',
                            alt((
                                value("\\", tag("\\")),
                                value("(", tag("(")),
                                value(")", tag(")")),
                            )),
                        ),
                        char(')'),
                    )),
                    preceded(char('"'), link_suffix),
                )),
            ),
        )),
    )(input)?;
    let content_string = content_vec.into_iter().collect::<String>();
    let content = match complete(phrase)(content_string.as_str()) {
        Ok((_, content)) => content,
        Err(_) => return fail(input),
    };
    Ok((
        rest,
        InlineTag::Link {
            attributes,
            title,
            url,
            content: Box::new(content),
        },
    ))
}

fn link_suffix(input: &str) -> IResult<&str, String> {
    preceded(
        char(':'),
        alt((
            delimited(char('('), link_url_parenthesized, char(')')),
            link_url_standard,
        )),
    )(input)
}

fn link_url_parenthesized(input: &str) -> IResult<&str, String> {
    let (rest, url) = escaped_transform(
        none_of("\\() \r\n\t"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
        )),
    )(input)?;
    Ok((rest, String::from(url)))
}

fn link_url_standard(input: &str) -> IResult<&str, String> {
    fold_many1(none_of(" \r\n\t"), String::new, |mut acc, c| {
        acc.push(c);
        acc
    })(input)
}

pub fn image(input: &str) -> IResult<&str, InlineTag> {
    let (rest, ((align, attributes, url, alt), opt_link)) = tuple((
        delimited(
            char('!'),
            tuple((
                opt(image_alignment),
                opt(attributes),
                image_url,
                opt(delimited(char('('), image_alt, char(')'))),
            )),
            char('!'),
        ),
        opt(link_suffix),
    ))(input)?;
    let img = InlineTag::Image {
        attributes,
        align,
        url,
        alt,
    };
    Ok((
        rest,
        match opt_link {
            Some(link) => InlineTag::Link {
                attributes: None,
                title: None,
                url: link,
                content: Box::new(img),
            },
            None => img,
        },
    ))
}

fn image_alignment(input: &str) -> IResult<&str, Align> {
    alt((
        value(Align::Left, tag("<")),
        value(Align::Right, tag(">")),
        value(Align::Center, tag("=")),
    ))(input)
}

fn image_url(input: &str) -> IResult<&str, String> {
    let (rest, url) = escaped_transform(
        none_of("\\()! \r\n\t"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
            value("!", tag("!")),
        )),
    )(input)?;
    Ok((rest, String::from(url)))
}

fn image_alt(input: &str) -> IResult<&str, String> {
    let (rest, alt) = escaped_transform(
        none_of("\\()!"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
            value("!", tag("!")),
        )),
    )(input)?;
    Ok((rest, String::from(alt)))
}

pub fn acronym(input: &str) -> IResult<&str, InlineTag> {
    let (rest, (caps, opt_title)) = tuple((
        capitals,
        opt(delimited(char('('), acronym_title, char(')'))),
    ))(input)?;
    alt((eof, take_while_m_n(1, 1, |c: char| !c.is_alphanumeric())))(rest)?;
    Ok((
        rest,
        InlineTag::Acronym {
            title: opt_title,
            content: caps,
        },
    ))
}

fn acronym_title(input: &str) -> IResult<&str, String> {
    let (rest, title) = escaped_transform(
        none_of("\\()"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
        )),
    )(input)?;
    Ok((rest, String::from(title)))
}

fn capitals(input: &str) -> IResult<&str, String> {
    let (rest, caps) = alt((
        // acronyms of the form ABC
        fold_many_m_n(
            2,
            usize::MAX,
            single_capital,
            String::new,
            |mut acc, c| {
                acc.push(c);
                acc
            },
        ),
        // acronyms of the form A.B.C.
        fold_many_m_n(
            2,
            usize::MAX,
            capital_with_dot,
            String::new,
            |mut acc, (c, dot)| {
                acc.push(c);
                acc.push(dot);
                acc
            },
        ),
    ))(input)?;
    Ok((rest, String::from(caps)))
}

fn capital_with_dot(input: &str) -> IResult<&str, (char, char)> {
    tuple((single_capital, char('.')))(input)
}

fn single_capital(input: &str) -> IResult<&str, char> {
    satisfy(|c| c.is_ascii_uppercase())(input)
}

#[allow(dead_code)]
fn vertical_alignment(input: &str) -> IResult<&str, VerticalAlign> {
    alt((
        value(VerticalAlign::Top, tag("^")),
        value(VerticalAlign::Middle, tag("-")),
        value(VerticalAlign::Bottom, tag("~")),
    ))(input)
}

R tests/acronym.rs => src/parse/acronym.rs +143 -64
@@ 1,74 1,153 @@
use flatiron::parse::*;
use crate::structs::InlineTag;
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, tag, take_while_m_n},
    character::complete::{char, none_of, satisfy},
    combinator::{eof, opt, value},
    multi::fold_many_m_n,
    sequence::{delimited, tuple},
    IResult,
};

#[test]
fn acronym_basic() {
    let input = "ACLU(American Civil Liberties Union)";
    let result = acronym(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Acronym {
                title: Some(String::from("American Civil Liberties Union")),
                content: String::from("ACLU")
            }
        ))
    );
pub fn acronym(input: &str) -> IResult<&str, InlineTag> {
    let (rest, (caps, opt_title)) = tuple((
        capitals,
        opt(delimited(char('('), acronym_title, char(')'))),
    ))(input)?;
    alt((eof, take_while_m_n(1, 1, |c: char| !c.is_alphanumeric())))(rest)?;
    Ok((
        rest,
        InlineTag::Acronym {
            title: opt_title,
            content: caps,
        },
    ))
}

#[test]
fn acronym_without_title() {
    let input = "ACLU";
    let result = acronym(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Acronym {
                title: None,
                content: String::from("ACLU")
            }
        ))
    );
fn acronym_title(input: &str) -> IResult<&str, String> {
    let (rest, title) = escaped_transform(
        none_of("\\()"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
        )),
    )(input)?;
    Ok((rest, String::from(title)))
}

#[test]
fn acronym_with_parentheses() {
    let input = "ACLU(American Civil Liberties Union \\(a union\\))";
    let result = acronym(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Acronym {
                title: Some(String::from(
                    "American Civil Liberties Union (a union)"
                )),
                content: String::from("ACLU")
            }
        ))
    );
fn capitals(input: &str) -> IResult<&str, String> {
    let (rest, caps) = alt((
        // acronyms of the form ABC
        fold_many_m_n(
            2,
            usize::MAX,
            single_capital,
            String::new,
            |mut acc, c| {
                acc.push(c);
                acc
            },
        ),
        // acronyms of the form A.B.C.
        fold_many_m_n(
            2,
            usize::MAX,
            capital_with_dot,
            String::new,
            |mut acc, (c, dot)| {
                acc.push(c);
                acc.push(dot);
                acc
            },
        ),
    ))(input)?;
    Ok((rest, String::from(caps)))
}

#[test]
fn acronym_with_dots() {
    let input = "A.C.L.U.(American Civil Liberties Union)";
    let result = acronym(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Acronym {
                title: Some(String::from("American Civil Liberties Union")),
                content: String::from("A.C.L.U.")
            }
        ))
    );
fn capital_with_dot(input: &str) -> IResult<&str, (char, char)> {
    tuple((single_capital, char('.')))(input)
}

#[test]
#[should_panic]
fn malformed_acronym_with_dots() {
    let input = "A.CL.U.(American CivilLiberties Union)";
    acronym(input).unwrap();
fn single_capital(input: &str) -> IResult<&str, char> {
    satisfy(|c| c.is_ascii_uppercase())(input)
}

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

    #[test]
    fn acronym_basic() {
        let input = "ACLU(American Civil Liberties Union)";
        let result = acronym(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Acronym {
                    title: Some(String::from("American Civil Liberties Union")),
                    content: String::from("ACLU")
                }
            ))
        );
    }

    #[test]
    fn acronym_without_title() {
        let input = "ACLU";
        let result = acronym(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Acronym {
                    title: None,
                    content: String::from("ACLU")
                }
            ))
        );
    }

    #[test]
    fn acronym_with_parentheses() {
        let input = "ACLU(American Civil Liberties Union \\(a union\\))";
        let result = acronym(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Acronym {
                    title: Some(String::from(
                        "American Civil Liberties Union (a union)"
                    )),
                    content: String::from("ACLU")
                }
            ))
        );
    }

    #[test]
    fn acronym_with_dots() {
        let input = "A.C.L.U.(American Civil Liberties Union)";
        let result = acronym(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Acronym {
                    title: Some(String::from("American Civil Liberties Union")),
                    content: String::from("A.C.L.U.")
                }
            ))
        );
    }

    #[test]
    #[should_panic]
    fn malformed_acronym_with_dots() {
        let input = "A.CL.U.(American CivilLiberties Union)";
        acronym(input).unwrap();
    }
}

A src/parse/attributes.rs => src/parse/attributes.rs +195 -0
@@ 0,0 1,195 @@
use crate::structs::Attributes;
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, tag},
    character::complete::{char, none_of},
    combinator::{fail, opt, value},
    sequence::{delimited, preceded, tuple},
    IResult,
};

pub fn attributes(mut input: &str) -> IResult<&str, Attributes> {
    let mut attributes = Attributes {
        class: None,
        id: None,
        style: None,
        language: None,
    };
    loop {
        if let Ok((rest, (class, id))) = class_and_id(input) {
            if attributes.class.is_none() {
                attributes.class = class;
            } else if class.is_some() {
                return Ok((input, attributes));
            }
            if attributes.id.is_none() {
                attributes.id = id;
            } else if id.is_some() {
                return Ok((input, attributes));
            }
            input = rest;
        } else if let Ok((rest, style)) = style(input) {
            if attributes.style.is_none() {
                attributes.style = Some(style);
            } else {
                return Ok((input, attributes));
            }
            input = rest;
        } else if let Ok((rest, language)) = language(input) {
            if attributes.language.is_none() {
                attributes.language = Some(language);
            } else {
                return Ok((input, attributes));
            }
            input = rest;
        } else {
            break;
        }
    }
    if attributes.class.is_some()
        || attributes.id.is_some()
        || attributes.style.is_some()
        || attributes.language.is_some()
    {
        Ok((input, attributes))
    } else {
        fail(input)
    }
}

fn class_and_id(
    input: &str,
) -> IResult<&str, (Option<String>, Option<String>)> {
    let (rest, (opt_class, opt_id)) = delimited(
        char('('),
        tuple((opt(class_name), opt(preceded(char('#'), class_name)))),
        char(')'),
    )(input)?;
    if opt_class.is_some() || opt_id.is_some() {
        Ok((rest, (opt_class, opt_id)))
    } else {
        fail(rest)
    }
}

fn class_name(input: &str) -> IResult<&str, String> {
    escaped_transform(
        none_of("\\#()"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
            value("#", tag("#")),
        )),
    )(input)
}

fn style(input: &str) -> IResult<&str, String> {
    delimited(
        char('{'),
        escaped_transform(
            none_of("\\{}"),
            '\\',
            alt((
                value("\\", tag("\\")),
                value("{", tag("{")),
                value("}", tag("}")),
            )),
        ),
        char('}'),
    )(input)
}

fn language(input: &str) -> IResult<&str, String> {
    delimited(
        char('['),
        escaped_transform(
            none_of("\\[]"),
            '\\',
            alt((
                value("\\", tag("\\")),
                value("[", tag("[")),
                value("]", tag("]")),
            )),
        ),
        char(']'),
    )(input)
}

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

    #[test]
    fn attributes_csl() {
        let input = "(class#id){style}[language]";
        let result = attributes(input);
        assert_eq!(
            result,
            Ok((
                "",
                Attributes {
                    class: Some(String::from("class")),
                    id: Some(String::from("id")),
                    style: Some(String::from("style")),
                    language: Some(String::from("language")),
                }
            ))
        );
    }

    #[test]
    fn attributes_scl() {
        let input = "{style}(class#id)[language]";
        let result = attributes(input);
        assert_eq!(
            result,
            Ok((
                "",
                Attributes {
                    class: Some(String::from("class")),
                    id: Some(String::from("id")),
                    style: Some(String::from("style")),
                    language: Some(String::from("language")),
                }
            ))
        );
    }

    #[test]
    fn attributes_lcs() {
        let input = "[language]{style}(class#id)";
        let result = attributes(input);
        assert_eq!(
            result,
            Ok((
                "",
                Attributes {
                    class: Some(String::from("class")),
                    id: Some(String::from("id")),
                    style: Some(String::from("style")),
                    language: Some(String::from("language")),
                }
            ))
        );
    }

    #[test]
    fn attributes_class() {
        let input = "(class)";
        let result = attributes(input);
        assert_eq!(
            result,
            Ok((
                "",
                Attributes {
                    class: Some(String::from("class")),
                    id: None,
                    style: None,
                    language: None,
                }
            ))
        );
    }
}

A src/parse/block.rs => src/parse/block.rs +497 -0
@@ 0,0 1,497 @@
use crate::parse::{
    attributes::attributes, from_num, list::list, phrase::phrase,
};
use crate::structs::{Align, Attributes, BlockKind, BlockTag, Indent};
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, tag, take_while1},
    character::complete::{char, line_ending, none_of},
    combinator::{complete, eof, fail, map_res, opt, value},
    multi::many0_count,
    sequence::{preceded, tuple},
    IResult,
};

pub fn block(input: &str) -> IResult<&str, BlockTag> {
    if input.is_empty() {
        return fail(input);
    }

    if let Ok((rest, list)) = list(input) {
        return Ok((rest, list));
    }

    let (rest, opt_header) = opt(block_header)(input)?;
    let (kind, attributes, opt_indent_align, extended) =
        opt_header.unwrap_or((BlockKind::Paragraph, None, None, false));
    let (indent, align) = opt_indent_align.unwrap_or((None, None));

    // get body of block to be parsed later.
    let mut i = 0;
    loop {
        match if extended {
            alt((eof, preceded(end_of_block, value("", block_header))))(
                &rest[i..],
            )
        } else {
            end_of_block(&rest[i..])
        } {
            Ok(_) => {
                break;
            }
            _ => {
                i += 1;
            }
        }
    }

    let body = &rest[..i];
    // consume interstitial newlines if they are there
    let (rest, _) = alt((eof, end_of_block))(&rest[i..])?;

    match kind {
        BlockKind::Paragraph
        | BlockKind::Header(_)
        | BlockKind::Footnote(_)
        | BlockKind::BlockQuote => {
            let (_, content) = complete(phrase)(body)?;
            Ok((
                rest,
                BlockTag::Basic {
                    kind,
                    indent,
                    align,
                    attributes,
                    content,
                },
            ))
        }
        BlockKind::BlockCode | BlockKind::Preformatted => {
            let mut content = String::from(body);
            if let Ok((_, stripped)) = complete(strip_flatiron_extended)(body) {
                content = stripped;
            }
            Ok((
                rest,
                BlockTag::Preformatted {
                    kind,
                    indent,
                    align,
                    attributes,
                    content,
                },
            ))
        }
        BlockKind::NoTextile => {
            let mut content = String::from(body);
            if let Ok((_, stripped)) = complete(strip_flatiron_extended)(body) {
                content = stripped;
            }
            Ok((rest, BlockTag::NoTextile(content)))
        }
    }
}

fn block_header(
    input: &str,
) -> IResult<
    &str,
    (
        BlockKind,
        Option<Attributes>,
        Option<(Option<Indent>, Option<Align>)>,
        bool,
    ),
> {
    tuple((
        block_kind,
        opt(attributes),
        opt(indent_align),
        alt((value(true, tag(".. ")), value(false, tag(". ")))), // is extended
    ))(input)
}

fn block_kind(input: &str) -> IResult<&str, BlockKind> {
    alt((
        value(BlockKind::Preformatted, tag("pre")),
        value(BlockKind::BlockCode, tag("bc")),
        value(BlockKind::BlockQuote, tag("bq")),
        value(BlockKind::Paragraph, tag("p")),
        value(BlockKind::NoTextile, tag("notextile")),
        header_modifier,
        footnote_modifier,
    ))(input)
}

fn header_modifier(input: &str) -> IResult<&str, BlockKind> {
    let (rest, n) = preceded(
        char('h'),
        alt((
            value(1, char('1')),
            value(2, char('2')),
            value(3, char('3')),
            value(4, char('4')),
            value(5, char('5')),
            value(6, char('6')),
        )),
    )(input)?;
    Ok((rest, BlockKind::Header(n)))
}

fn footnote_modifier(input: &str) -> IResult<&str, BlockKind> {
    let (rest, n) = preceded(
        tag("fn"),
        map_res(take_while1(|c: char| c.is_ascii_digit()), from_num),
    )(input)?;
    Ok((rest, BlockKind::Footnote(n)))
}

pub fn indent_align(
    mut input: &str,
) -> IResult<&str, (Option<Indent>, Option<Align>)> {
    let (mut opt_indent, mut opt_align) = (None, None);
    loop {
        if let Ok((rest, i)) = indent(input) {
            if opt_indent.is_none() {
                opt_indent = Some(i);
                input = rest;
            } else {
                return Ok((input, (opt_indent, opt_align)));
            }
        } else if let Ok((rest, a)) = alignment(input) {
            if opt_align.is_none() {
                opt_align = Some(a);
                input = rest;
            } else {
                return Ok((input, (opt_indent, opt_align)));
            }
        } else {
            break;
        }
    }

    if opt_indent.is_some() || opt_align.is_some() {
        Ok((input, (opt_indent, opt_align)))
    } else {
        fail(input)
    }
}

fn indent(input: &str) -> IResult<&str, Indent> {
    let (rest, (left, right)) =
        tuple((many0_count(char('(')), many0_count(char(')'))))(input)?;
    if left + right > 0 {
        Ok((rest, Indent { left, right }))
    } else {
        fail(input)
    }
}

fn alignment(input: &str) -> IResult<&str, Align> {
    alt((
        value(Align::Justify, tag("<>")),
        value(Align::Left, tag("<")),
        value(Align::Right, tag(">")),
        value(Align::Center, tag("=")),
    ))(input)
}

pub fn end_of_block(input: &str) -> IResult<&str, &str> {
    alt((
        eof,
        value("", tuple((line_ending, line_ending))),
        value("", tuple((line_ending, eof))),
    ))(input)
}

pub fn strip_flatiron_extended(input: &str) -> IResult<&str, String> {
    escaped_transform(none_of("\n"), '\n', value("\n", tag("|")))(input)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::structs::InlineTag;

    #[test]
    fn block_basic() {
        let input = "Untagged paragraph";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from(
                        "Untagged paragraph"
                    ))
                }
            ))
        );
    }

    #[test]
    fn explicit_paragraph() {
        let input = "p. Tagged paragraph";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from(
                        "Tagged paragraph"
                    ))
                }
            ))
        );
    }

    #[test]
    fn block_pre() {
        let input = "pre. Preformatted _paragraph_";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Preformatted {
                    kind: BlockKind::Preformatted,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: String::from("Preformatted _paragraph_")
                }
            ))
        );
    }

    #[test]
    fn block_notextile() {
        let input = "notextile. **Notextile** paragraph";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::NoTextile(String::from("**Notextile** paragraph"))
            ))
        );
    }

    #[test]
    fn extended_block() {
        let input =
            "p.. Extended paragraph\n\nWhere will she go?\n\np. Hell if I know.";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "p. Hell if I know.",
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Phrase {
                        kind: None,
                        attributes: None,
                        content: vec![
                            InlineTag::Plaintext(String::from(
                                "Extended paragraph"
                            )),
                            InlineTag::LineBreak,
                            InlineTag::LineBreak,
                            InlineTag::Plaintext(String::from(
                                "Where will she go?"
                            ))
                        ]
                    }
                }
            ))
        );
    }

    #[test]
    fn block_with_attributes() {
        let input = "h2(class){color:green}. This is a title";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Header(2),
                    indent: None,
                    align: None,
                    attributes: Some(Attributes {
                        class: Some(String::from("class")),
                        id: None,
                        style: Some(String::from("color:green")),
                        language: None,
                    }),
                    content: InlineTag::Plaintext(String::from(
                        "This is a title"
                    ))
                }
            ))
        );
    }

    #[test]
    fn centered_block() {
        let input = "h2=. This is a title";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Header(2),
                    indent: None,
                    align: Some(Align::Center),
                    attributes: None,
                    content: InlineTag::Plaintext(String::from(
                        "This is a title"
                    ))
                }
            ))
        );
    }

    #[test]
    fn indented_block() {
        let input = "h2(). This is a title";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Header(2),
                    indent: Some(Indent { left: 1, right: 1 }),
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from(
                        "This is a title"
                    ))
                }
            ))
        );
    }

    #[test]
    fn indented_aligned_block() {
        let input = "p))>. I am a fish!";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: Some(Indent { left: 0, right: 2 }),
                    align: Some(Align::Right),
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("I am a fish!"))
                }
            ))
        );
    }

    #[test]
    fn aligned_indented_block() {
        let input = "p>)). I am a transmitter!";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: Some(Indent { left: 0, right: 2 }),
                    align: Some(Align::Right),
                    attributes: None,
                    content: InlineTag::Plaintext(String::from(
                        "I am a transmitter!"
                    ))
                }
            ))
        );
    }

    #[test]
    fn block_with_attributes_indent_align() {
        let input = "p(greeting){color:green}[fr]()>. Salut!";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: Some(Indent { left: 1, right: 1 }),
                    align: Some(Align::Right),
                    attributes: Some(Attributes {
                        class: Some(String::from("greeting")),
                        id: None,
                        style: Some(String::from("color:green")),
                        language: Some(String::from("fr")),
                    }),
                    content: InlineTag::Plaintext(String::from("Salut!"))
                }
            ))
        );
    }

    #[test]
    fn flatiron_blockcode() {
        let input = "bc. This is supposed to be a block of textile code.
|
|bq. Textile should really have a closing delimiter for blocks.
|--Autumn
|
|Here's some textile code within the textile code:
|
|bc.
||bq. This is getting out of hand... now there are two of them!
||--Nute Gunray
|
|Now some more text within the code block";
        let result = block(input);
        assert_eq!(
            result,
            Ok((
                "",
                BlockTag::Preformatted {
                    kind: BlockKind::BlockCode,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: String::from(
                        "This is supposed to be a block of textile code.

bq. Textile should really have a closing delimiter for blocks.
--Autumn

Here's some textile code within the textile code:

bc.
|bq. This is getting out of hand... now there are two of them!
|--Nute Gunray

Now some more text within the code block"
                    )
                }
            ))
        );
    }
}

R tests/image.rs => src/parse/image.rs +204 -120
@@ 1,138 1,222 @@
use flatiron::parse::*;
use crate::parse::{attributes, link::link_suffix};
use crate::structs::{Align, InlineTag};
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, tag},
    character::complete::{char, none_of},
    combinator::{opt, value},
    sequence::{delimited, tuple},
    IResult,
};

#[test]
fn image_basic() {
    let input =
        "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!";
    let result = image(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Image {
pub fn image(input: &str) -> IResult<&str, InlineTag> {
    let (rest, ((align, attributes, url, alt), opt_link)) = tuple((
        delimited(
            char('!'),
            tuple((
                opt(image_alignment),
                opt(attributes::attributes),
                image_url,
                opt(delimited(char('('), image_alt, char(')'))),
            )),
            char('!'),
        ),
        opt(link_suffix),
    ))(input)?;
    let img = InlineTag::Image {
        attributes,
        align,
        url,
        alt,
    };
    Ok((
        rest,
        match opt_link {
            Some(link) => InlineTag::Link {
                attributes: None,
                align: None,
                url: String::from(
                    "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png",
                ),
                alt: None
            }
        ))
    );
                title: None,
                url: link,
                content: Box::new(img),
            },
            None => img,
        },
    ))
}

#[test]
fn image_with_alt() {
    let input =
        "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png(Flatiron logo)!";
    let result = image(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Image {
                attributes: None,
                align: None,
                url: String::from(
                    "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png",
                ),
                alt: Some(String::from("Flatiron logo")),
            }
        ))
    );
fn image_alignment(input: &str) -> IResult<&str, Align> {
    alt((
        value(Align::Left, tag("<")),
        value(Align::Right, tag(">")),
        value(Align::Center, tag("=")),
    ))(input)
}

#[test]
fn image_with_parentheses() {
    let input =
        "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png\\(parentheses\\)!";
    let result = image(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Image {
                attributes: None,
                align: None,
                url: String::from(
                    "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png(parentheses)",
                ),
                alt: None
            }
        ))
    );
fn image_url(input: &str) -> IResult<&str, String> {
    let (rest, url) = escaped_transform(
        none_of("\\()! \r\n\t"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
            value("!", tag("!")),
        )),
    )(input)?;
    Ok((rest, String::from(url)))
}

#[test]
fn image_with_exclamation() {
    let input =
        "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png\\!\\!!";
    let result = image(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Image {
                attributes: None,
                align: None,
                url: String::from(
                    "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!!",
                ),
                alt: None,
            }
        ))
    );
fn image_alt(input: &str) -> IResult<&str, String> {
    let (rest, alt) = escaped_transform(
        none_of("\\()!"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
            value("!", tag("!")),
        )),
    )(input)?;
    Ok((rest, String::from(alt)))
}

#[test]
#[should_panic]
fn not_an_image() {
    let input = "Stop! This is not an image, leave me alone!";
    image(input).unwrap();
}
#[cfg(test)]
mod tests {
    use super::*;

#[test]
fn image_link() {
    let input = "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!:https://github.com/autumnull/flatiron";
    let result = image(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Link {
                attributes: None,
                title: None,
                url: String::from("https://github.com/autumnull/flatiron"),
                content: Box::new(InlineTag::Image {
    #[test]
    fn image_basic() {
        let input =
            "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!";
        let result = image(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Image {
                    attributes: None,
                    align: None,
                    url: String::from("https://github.com/autumnull/flatiron/raw/main/images/flatiron.png"),
                    alt: None,
                })
            }
        ))
    );
}
                    url: String::from(
                        "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png",
                    ),
                    alt: None
                }
            ))
        );
    }

#[test]
fn image_link_parenthesized() {
    let input = "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!:(https://github.com/autumnull/flatiron)";
    let result = image(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Link {
                attributes: None,
                title: None,
                url: String::from("https://github.com/autumnull/flatiron"),
                content: Box::new(InlineTag::Image {
    #[test]
    fn image_with_alt() {
        let input =
            "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png(Flatiron logo)!";
        let result = image(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Image {
                    attributes: None,
                    align: None,
                    url: String::from("https://github.com/autumnull/flatiron/raw/main/images/flatiron.png"),
                    url: String::from(
                        "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png",
                    ),
                    alt: Some(String::from("Flatiron logo")),
                }
            ))
        );
    }

    #[test]
    fn image_with_parentheses() {
        let input =
            "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png\\(parentheses\\)!";
        let result = image(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Image {
                    attributes: None,
                    align: None,
                    url: String::from(
                        "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png(parentheses)",
                    ),
                    alt: None
                }
            ))
        );
    }

    #[test]
    fn image_with_exclamation() {
        let input =
            "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png\\!\\!!";
        let result = image(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Image {
                    attributes: None,
                    align: None,
                    url: String::from(
                        "https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!!",
                    ),
                    alt: None,
                })
            }
        ))
    );
                }
            ))
        );
    }

    #[test]
    #[should_panic]
    fn not_an_image() {
        let input = "Stop! This is not an image, leave me alone!";
        image(input).unwrap();
    }

    #[test]
    fn image_link() {
        let input = "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!:https://github.com/autumnull/flatiron";
        let result = image(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Link {
                    attributes: None,
                    title: None,
                    url: String::from("https://github.com/autumnull/flatiron"),
                    content: Box::new(InlineTag::Image {
                        attributes: None,
                        align: None,
                        url: String::from("https://github.com/autumnull/flatiron/raw/main/images/flatiron.png"),
                        alt: None,
                    })
                }
            ))
        );
    }

    #[test]
    fn image_link_parenthesized() {
        let input = "!https://github.com/autumnull/flatiron/raw/main/images/flatiron.png!:(https://github.com/autumnull/flatiron)";
        let result = image(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Link {
                    attributes: None,
                    title: None,
                    url: String::from("https://github.com/autumnull/flatiron"),
                    content: Box::new(InlineTag::Image {
                        attributes: None,
                        align: None,
                        url: String::from("https://github.com/autumnull/flatiron/raw/main/images/flatiron.png"),
                        alt: None,
                    })
                }
            ))
        );
    }
}

A src/parse/link.rs => src/parse/link.rs +209 -0
@@ 0,0 1,209 @@
use crate::parse::{attributes::attributes, phrase::phrase};
use crate::structs::InlineTag;
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, tag},
    character::complete::{char, none_of},
    combinator::{complete, fail, opt, value},
    multi::{fold_many1, many_till},
    sequence::{delimited, preceded, tuple},
    IResult,
};

pub fn link(input: &str) -> IResult<&str, InlineTag> {
    let (rest, (attributes, (content_vec, (title, url)))) = preceded(
        char('"'),
        tuple((
            opt(attributes),
            many_till(
                alt((
                    value('\\', tag("\\\\")),
                    value('"', tag("\\\"")),
                    value('(', tag("\\(")),
                    value(')', tag("\\)")),
                    none_of("\\\"()"),
                )),
                tuple((
                    opt(delimited(
                        tag(" ("),
                        escaped_transform(
                            none_of("\\()"),
                            '\\',
                            alt((
                                value("\\", tag("\\")),
                                value("(", tag("(")),
                                value(")", tag(")")),
                            )),
                        ),
                        char(')'),
                    )),
                    preceded(char('"'), link_suffix),
                )),
            ),
        )),
    )(input)?;
    let content_string = content_vec.into_iter().collect::<String>();
    let content = match complete(phrase)(content_string.as_str()) {
        Ok((_, content)) => content,
        Err(_) => return fail(input),
    };
    Ok((
        rest,
        InlineTag::Link {
            attributes,
            title,
            url,
            content: Box::new(content),
        },
    ))
}

pub fn link_suffix(input: &str) -> IResult<&str, String> {
    preceded(
        char(':'),
        alt((
            delimited(char('('), link_url_parenthesized, char(')')),
            link_url_standard,
        )),
    )(input)
}

fn link_url_parenthesized(input: &str) -> IResult<&str, String> {
    let (rest, url) = escaped_transform(
        none_of("\\() \r\n\t"),
        '\\',
        alt((
            value("\\", tag("\\")),
            value("(", tag("(")),
            value(")", tag(")")),
        )),
    )(input)?;
    Ok((rest, String::from(url)))
}

fn link_url_standard(input: &str) -> IResult<&str, String> {
    fold_many1(none_of(" \r\n\t"), String::new, |mut acc, c| {
        acc.push(c);
        acc
    })(input)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::structs::Attributes;

    #[test]
    fn link_basic() {
        let input = "\"a link\":https://github.com/autumnull/flatiron";
        let result = link(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Link {
                    attributes: None,
                    title: None,
                    url: String::from("https://github.com/autumnull/flatiron"),
                    content: Box::new(InlineTag::Plaintext(String::from(
                        "a link"
                    )))
                }
            ))
        );
    }

    #[test]
    fn link_attributes() {
        let input = "\"(class)a link\":https://github.com/autumnull/flatiron";
        let result = link(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Link {
                    attributes: Some(Attributes {
                        class: Some(String::from("class")),
                        id: None,
                        style: None,
                        language: None,
                    }),
                    title: None,
                    url: String::from("https://github.com/autumnull/flatiron"),
                    content: Box::new(InlineTag::Plaintext(String::from(
                        "a link"
                    )))
                }
            ))
        );
    }

    #[test]
    fn link_attributes_title() {
        let input =
            "\"(class)a link (title)\":https://github.com/autumnull/flatiron";
        let result = link(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Link {
                    attributes: Some(Attributes {
                        class: Some(String::from("class")),
                        id: None,
                        style: None,
                        language: None,
                    }),
                    title: Some(String::from("title")),
                    url: String::from("https://github.com/autumnull/flatiron"),
                    content: Box::new(InlineTag::Plaintext(String::from(
                        "a link"
                    )))
                }
            ))
        );
    }

    #[test]
    fn link_escaped() {
        let input =
            "\"Earvin \\\"Magic\\\" Johnson \\(Basketball Player\\)\":https://en.wikipedia.org/wiki/Magic_Johnson";
        let result = link(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Link {
                    attributes: None,
                    title: None,
                    url: String::from(
                        "https://en.wikipedia.org/wiki/Magic_Johnson"
                    ),
                    content: Box::new(InlineTag::Plaintext(String::from(
                        "Earvin \"Magic\" Johnson (Basketball Player)"
                    )))
                }
            ))
        );
    }

    #[test]
    fn link_parenthesized() {
        let input = "\"this\":(https://github.com/autumnull/flatiron)?";
        let result = link(input);
        assert_eq!(
            result,
            Ok((
                "?",
                InlineTag::Link {
                    attributes: None,
                    title: None,
                    url: String::from("https://github.com/autumnull/flatiron"),
                    content: Box::new(InlineTag::Plaintext(String::from(
                        "this"
                    )))
                }
            ))
        );
    }
}

A src/parse/list.rs => src/parse/list.rs +106 -0
@@ 0,0 1,106 @@
use crate::parse::{attributes, block, phrase};
use crate::structs::{BlockTag, InlineTag, List, ListItem, ListKind};
use nom::{
    branch::alt,
    character::complete::{char, line_ending},
    combinator::{fail, map, opt, value},
    multi::many1_count,
    sequence::{delimited, terminated, tuple},
    IResult,
};

pub fn list(input: &str) -> IResult<&str, BlockTag> {
    let (rest, (kind, attributes, opt_indent_align)) = terminated(
        tuple((
            alt((
                value(ListKind::Numeric, char('#')),
                value(ListKind::Bulleted, char('*')),
            )),
            opt(attributes::attributes),
            opt(block::indent_align),
        )),
        char(' '),
    )(input)?;
    let (indent, align) = opt_indent_align.unwrap_or((None, None));

    let mut all_items: Vec<(ListKind, usize, InlineTag)> = Vec::new();
    // get first item
    let (rest, first_content) = list_item_content(rest)?;
    all_items.push((kind, 1, first_content));

    // collect all items with depth information
    let mut input = rest;
    while let Err(_) = block::end_of_block(input) {
        let (rest, (item_kind, depth)) = list_item_head(input)?;
        let (rest, content) = list_item_content(rest)?;
        all_items.push((item_kind, depth, content));
        input = rest;
    }

    // convert flat items list to nested list
    match list_items_to_nested_list(&mut all_items) {
        Ok(l) => Ok((
            input,
            BlockTag::List {
                indent,
                align,
                attributes,
                content: l,
            },
        )),
        Err(_) => fail(input),
    }
}

fn list_item_head(input: &str) -> IResult<&str, (ListKind, usize)> {
    delimited(
        line_ending,
        alt((
            map(many1_count(char('#')), |n| (ListKind::Numeric, n)),
            map(many1_count(char('*')), |n| (ListKind::Bulleted, n)),
        )),
        char(' '),
    )(input)
}

fn list_item_content(input: &str) -> IResult<&str, InlineTag> {
    let mut i = 0;
    while let Err(_) = end_list_item(&input[i..]) {
        i += 1
    }
    let (_, content) = phrase::phrase(&input[..i])?;
    Ok((&input[i..], content))
}

fn end_list_item(input: &str) -> IResult<&str, &str> {
    alt((value("", list_item_head), block::end_of_block))(input)
}

fn list_items_to_nested_list(
    list: &mut Vec<(ListKind, usize, InlineTag)>,
) -> Result<List, String> {
    let mut items: Vec<ListItem> = Vec::new();
    let kind = list[0].0;
    let depth = list[0].1;
    while !list.is_empty() {
        if list[0].1 > depth {
            // unwrap should be safe because depth can only be greater after 1st item
            items.last_mut().unwrap().sublist =
                Some(list_items_to_nested_list(list)?);
        } else if list[0].1 < depth {
            break;
        } else if list[0].0 != kind {
            return Err(format!(
                "List uses mixed markers on same level: {:?}",
                list[0].2
            ));
        } else {
            let (_, _, content) = list.remove(0);
            items.push(ListItem {
                content,
                sublist: None,
            })
        }
    }
    Ok(List { kind, items })
}

R tests/textile.rs => src/parse/mod.rs +556 -469
@@ 1,484 1,571 @@
use flatiron::parse::*;
use crate::structs::{InlineTag, PhraseKind, Textile};
use nom::{
    bytes::complete::tag,
    character::complete::line_ending,
    multi::many0,
    sequence::{preceded, terminated},
    IResult,
};

#[test]
fn textile_basic() {
    let input = "h2{color:green}. This is a title
mod acronym;
mod attributes;
mod block;
mod image;
mod link;
mod list;
mod no_textile;
mod phrase;
mod table;

impl PhraseKind {
    fn list() -> &'static [PhraseKind] {
        use PhraseKind::*;
        &[
            Italic,
            Bold,
            Emphasis,
            Strong,
            Citation,
            Deleted,
            Inserted,
            Superscript,
            Subscript,
            Span,
        ]
    }

    fn delimiter(self, input: &str) -> IResult<&str, &str> {
        use PhraseKind::*;
        match self {
            Italic => tag("__")(input),
            Bold => tag("**")(input),
            Emphasis => tag("_")(input),
            Strong => tag("*")(input),
            Citation => tag("??")(input),
            Deleted => tag("#")(input),
            Inserted => tag("+")(input),
            Superscript => tag("^")(input),
            Subscript => tag("~")(input),
            Span => tag("%")(input),
        }
    }
}

impl InlineTag {
    fn parsers() -> &'static [for<'r> fn(
        &'r str,
    ) -> Result<
        (&'r str, InlineTag),
        nom::Err<nom::error::Error<&'r str>>,
    >] {
        &[
            phrase::line_break,
            no_textile::no_textile,
            phrase::code,
            phrase::footnote_ref,
            link::link,
            image::image,
        ]
    }
}

pub fn textile(input: &str) -> IResult<&str, Textile> {
    let (rest, blocks) = preceded(
        many0(line_ending),
        many0(terminated(block::block, many0(line_ending))),
    )(input)?;
    Ok((rest, Textile(blocks)))
}

fn from_num(input: &str) -> Result<usize, std::num::ParseIntError> {
    usize::from_str_radix(input, 10)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::structs::{
        Attributes, BlockKind, BlockTag, List, ListItem, ListKind,
    };

    #[test]
    fn textile_basic() {
        let input = "h2{color:green}. This is a title

This is a paragraph with some _emphasized text_.";
    let result = textile(input);
    assert_eq!(
        result,
        Ok((
            "",
            Textile(vec![
                BlockTag::Basic {
                    kind: BlockKind::Header(2),
                    indent: None,
                    align: None,
                    attributes: Some(Attributes {
                        class: None,
                        id: None,
                        style: Some(String::from("color:green")),
                        language: None,
                    }),
                    content: InlineTag::Plaintext(String::from(
                        "This is a title"
                    ))
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Phrase {
                        kind: None,
        let result = textile(input);
        assert_eq!(
            result,
            Ok((
                "",
                Textile(vec![
                    BlockTag::Basic {
                        kind: BlockKind::Header(2),
                        indent: None,
                        align: None,
                        attributes: Some(Attributes {
                            class: None,
                            id: None,
                            style: Some(String::from("color:green")),
                            language: None,
                        }),
                        content: InlineTag::Plaintext(String::from(
                            "This is a title"
                        ))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: vec![
                            InlineTag::Plaintext(String::from(
                                "This is a paragraph with some "
                            )),
                            InlineTag::Phrase {
                                kind: Some(PhraseKind::Emphasis),
                                attributes: None,
                                content: vec![InlineTag::Plaintext(
                                    String::from("emphasized text")
                                )]
                            },
                            InlineTag::Plaintext(String::from(".")),
                        ]
                        content: InlineTag::Phrase {
                            kind: None,
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from(
                                    "This is a paragraph with some "
                                )),
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Emphasis),
                                    attributes: None,
                                    content: vec![InlineTag::Plaintext(
                                        String::from("emphasized text")
                                    )]
                                },
                                InlineTag::Plaintext(String::from(".")),
                            ]
                        }
                    }
                }
            ])
        ))
    );
}
                ])
            ))
        );
    }

/*
#[test]
fn textile_sample() {
    let input = include_str!("sample.textile");
    let result = textile(input);
    assert_eq!(
        result,
        Ok((
            "",
            Textile(vec![
                BlockTag::Basic {
                    kind: BlockKind::Header(2),
                    indent: None,
                    align: None,
                    attributes: Some(Attributes {
                        class: None,
                        id: None,
                        style: Some(String::from("color:green")),
                        language: None
                    }),
                    content: InlineTag::Plaintext(String::from("This is a title"))
                },
                BlockTag::Basic {
                    kind: BlockKind::Header(3),
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("This is a subhead"))
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: Some(Attributes {
                        class: None,
                        id: None,
                        style: Some(String::from("color:red")),
                        language: None
                    }),
                    content: InlineTag::Plaintext(String::from("This is some text of dubious character. Isn't the use of \"quotes\" just lazy writing -- and theft of 'intellectual property' besides? I think the time has come to see a block quote."))
                },
                BlockTag::Basic {
                    kind: BlockKind::BlockQuote,
                    indent: None,
                    align: None,
                    attributes: Some(Attributes {
                        class: None,
                        id: None,
                        style: None,
                        language: Some(String::from("fr"))
                    }),
                    content: InlineTag::Plaintext(String::from("This is a block quote. I'll admit it's not the most exciting block quote ever devised."))
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("Simple list:"))
                },
                BlockTag::List{
                    indent: None,
                    align: None,
                    attributes: Some(Attributes {
                        class: None,
                        id: None,
                        style: Some(String::from("color:blue")),
                        language: None
                    }),
                    content: List {
                        kind: ListKind::Numeric,
                        items: vec![
                            ListItem {
                                content: InlineTag::Plaintext(String::from("one")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("two")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("three")),
                                sublist: None,
                            },
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("Multi-level list:"))
                },
                BlockTag::List{
                    indent: None,
                    align: None,
                    attributes: None,
                    content: List {
                        kind: ListKind::Numeric,
                        items: vec![
                            ListItem {
                                content: InlineTag::Plaintext(String::from("one")),
                                sublist: Some(List {
                                    kind: ListKind::Numeric,
                                    items: vec![
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("aye")),
                                            sublist: None,
                                        },
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("bee")),
                                            sublist: None,
                                        },
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("see")),
                                            sublist: None,
                                        }
                                    ],
                                }),
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("two")),
                                sublist: Some(List {
                                    kind: ListKind::Numeric,
                                    items: vec![
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("x")),
                                            sublist: None,
                                        },
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("y")),
                                            sublist: None,
                                        },
                                    ],
                                }),
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("three")),
                                sublist: None,
                            },
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("Mixed list:"))
                },
                BlockTag::List{
                    indent: None,
                    align: None,
                    attributes: None,
                    content: List {
                        kind: ListKind::Bulleted,
                        items: vec![
                            ListItem {
                                content: InlineTag::Plaintext(String::from("Point one")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("Point two")),
                                sublist: Some(List {
                                    kind: ListKind::Numeric,
                                    items: vec![
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("Step 1")),
                                            sublist: None,
                                        },
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("Step 2")),
                                            sublist: None,
                                        },
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("Step 3")),
                                            sublist: None,
                                        }
                                    ],
                                }),
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("Point three")),
                                sublist: Some(List {
                                    kind: ListKind::Bulleted,
                                    items: vec![
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("Sub point 1")),
                                            sublist: None,
                                        },
                                        ListItem {
                                            content: InlineTag::Plaintext(String::from("Sub point 2")),
                                            sublist: None,
                                        },
                                    ],
                                }),
                            },
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Phrase {
                        kind: None,
    #[test]
    fn textile_sample() {
        let input = include_str!("../../samples/textism.textile");
        let result = textile(input);
        assert_eq!(
            result,
            Ok((
                "",
                Textile(vec![
                    BlockTag::Basic {
                        kind: BlockKind::Header(2),
                        indent: None,
                        align: None,
                        attributes: Some(Attributes {
                            class: None,
                            id: None,
                            style: Some(String::from("color:green")),
                            language: None
                        }),
                        content: InlineTag::Plaintext(String::from("This is a title"))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Header(3),
                        indent: None,
                        align: None,
                        attributes: None,
                        content: vec![
                            InlineTag::Plaintext(String::from("Well, that went well. How about we insert an ")),
                            InlineTag::NoTextile(String::from("<a href=\"http://www.textism.com/\" title=\"watch out\">old-fashioned hypertext link</a>")),
                            InlineTag::Plaintext(String::from("? Will the quote marks in the tags get messed up? No!")),
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Link {
                        content: InlineTag::Plaintext(String::from("This is a subhead"))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: Some(Attributes {
                            class: None,
                            id: None,
                            style: Some(String::from("color:red")),
                            language: None
                        }),
                        content: InlineTag::Plaintext(String::from("This is some text of dubious character. Isn't the use of \"quotes\" just lazy writing -- and theft of 'intellectual property' besides? I think the time has come to see a block quote."))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::BlockQuote,
                        indent: None,
                        align: None,
                        attributes: Some(Attributes {
                            class: None,
                            id: None,
                            style: None,
                            language: Some(String::from("fr"))
                        }),
                        content: InlineTag::Plaintext(String::from("This is a block quote. I'll admit it's not the most exciting block quote ever devised."))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        title: Some(String::from("optional title")),
                        url: String::from("http://www.textism.com"),
                        content: Box::new(
                            InlineTag::Plaintext(String::from("This is a link"))
                        )
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Phrase {
                        kind: None,
                        content: InlineTag::Plaintext(String::from("Simple list:"))
                    },
                    BlockTag::List{
                        indent: None,
                        align: None,
                        attributes: Some(Attributes {
                            class: None,
                            id: None,
                            style: Some(String::from("color:blue")),
                            language: None
                        }),
                        content: List {
                            kind: ListKind::Numeric,
                            items: vec![
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("one")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("two")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("three")),
                                    sublist: None,
                                },
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: vec![
                            InlineTag::Plaintext(String::from("table{border:1px solid black}.")),
                            InlineTag::LineBreak,
                            InlineTag::Plaintext(String::from("|")),
                            InlineTag::Phrase {
                                kind: Some(PhraseKind::Emphasis),
                                attributes: None,
                                content: vec![
                                    InlineTag::Plaintext(String::from(". this|"))
                                ]
                            },
                            InlineTag::Plaintext(String::from(". is|")),
                            InlineTag::Phrase {
                                kind: Some(PhraseKind::Emphasis),
                                attributes: None,
                                content: vec![
                                    InlineTag::Plaintext(String::from(". a|"))
                                ]
                            },
                            InlineTag::Plaintext(String::from(". header|")),
                            InlineTag::LineBreak,
                            InlineTag::Plaintext(String::from("<{background:gray}. |\\2. this is|{background:red;width:200px}. a|")),
                            InlineTag::Phrase {
                                kind: Some(PhraseKind::Superscript),
                                attributes: None,
                                content: vec![
                                    InlineTag::Plaintext(String::from("<>{height:200px}. row|")),
                                    InlineTag::LineBreak,
                                    InlineTag::Plaintext(String::from("|this|<>{padding:10px}. is|"))
                                ]
                            },
                            InlineTag::Plaintext(String::from(". another|(bob#bob). row|"))
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("An image:"))
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Image {
                        content: InlineTag::Plaintext(String::from("Multi-level list:"))
                    },
                    BlockTag::List{
                        indent: None,
                        align: None,
                        attributes: None,
                        content: List {
                            kind: ListKind::Numeric,
                            items: vec![
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("one")),
                                    sublist: Some(List {
                                        kind: ListKind::Numeric,
                                        items: vec![
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("aye")),
                                                sublist: None,
                                            },
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("bee")),
                                                sublist: None,
                                            },
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("see")),
                                                sublist: None,
                                            }
                                        ],
                                    }),
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("two")),
                                    sublist: Some(List {
                                        kind: ListKind::Numeric,
                                        items: vec![
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("x")),
                                                sublist: None,
                                            },
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("y")),
                                                sublist: None,
                                            },
                                        ],
                                    }),
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("three")),
                                    sublist: None,
                                },
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        url: String::from("/textist.gif"),
                        alt: Some(String::from("optional alt text"))
                    }
                },
                BlockTag::List{
                    indent: None,
                    align: None,
                    attributes: None,
                    content: List {
                        kind: ListKind::Numeric,
                        items: vec![
                            ListItem {
                                content: InlineTag::Plaintext(String::from("Librarians rule")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("Yes they do")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("But you knew that")),
                                sublist: None,
                            },
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Phrase {
                        kind: None,
                        attributes: None,
                        content: vec![
                            InlineTag::Plaintext(String::from("Some more text of dubious character. Here is a noisome string of ")),
                            InlineTag::Acronym {
                                title: None,
                                content: String::from("CAPITAL")
                            },
                            InlineTag::Plaintext(String::from(" letters. Here is something we want to ")),
                            InlineTag::Phrase {
                                kind: Some(PhraseKind::Emphasis),
                                attributes: None,
                                content: vec![
                                    InlineTag::Plaintext(String::from("emphasize"))
                                ]
                            },
                            InlineTag::Plaintext(String::from(".")),
                            InlineTag::LineBreak,
                            InlineTag::Plaintext(String::from("That was a linebreak. And something to indicate ")),
                            InlineTag::Phrase {
                                kind: Some(PhraseKind::Strong),
                                attributes: None,
                                content: vec![
                                    InlineTag::Plaintext(String::from("strength"))
                                ]
                            },
                            InlineTag::Plaintext(String::from(". Of course I could use <em>my own ")),
                            InlineTag::Acronym {
                                title: None,
                                content: String::from("HTML")
                            },
                            InlineTag::Plaintext(String::from(" tags</em> if I <strong>felt</strong> like it."))
                        ]
                    }
                },
                BlockTag::Basic {
                    kind: BlockKind::Header(3),
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("Coding"))
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Phrase {
                        kind: None,
                        content: InlineTag::Plaintext(String::from("Mixed list:"))
                    },
                    BlockTag::List{
                        indent: None,
                        align: None,
                        attributes: None,
                        content: vec![
                            InlineTag::Plaintext(String::from("This ")),
                            InlineTag::Code(String::from("is some code, \"isn't it\"")),
                            InlineTag::Plaintext(String::from(". Watch those quote marks! Now for some preformatted text:"))
                        ]
                    }
                },
                BlockTag::Preformatted {
                    kind: BlockKind::BlockCode,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: String::from("$text = str_replace(\"<p>%::%</p>\",\"\",$text);\n$text = str_replace(\"%::%</p>\",\"\",$text);\n$text = str_replace(\"%::%\",\"\",$text);")
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("This isn't code."))
                },
                BlockTag::Basic {
                    kind: BlockKind::Paragraph,
                    indent: None,
                    align: None,
                    attributes: None,
                    content: InlineTag::Plaintext(String::from("So you see, my friends:"))
                },
                BlockTag::List {
                    indent: None,
                    align: None,
                    attributes: None,
                    content: List {
                        kind: ListKind::Bulleted,
                        items: vec![
                            ListItem {
                                content: InlineTag::Plaintext(String::from("The time is now")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("The time is not later")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("The time is not yesterday")),
                                sublist: None,
                            },
                            ListItem {
                                content: InlineTag::Plaintext(String::from("We must act")),
                                sublist: None,
                            },
                        ]
                        content: List {
                            kind: ListKind::Bulleted,
                            items: vec![
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("Point one")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("Point two")),
                                    sublist: Some(List {
                                        kind: ListKind::Numeric,
                                        items: vec![
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("Step 1")),
                                                sublist: None,
                                            },
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("Step 2")),
                                                sublist: None,
                                            },
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("Step 3")),
                                                sublist: None,
                                            }
                                        ],
                                    }),
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("Point three")),
                                    sublist: Some(List {
                                        kind: ListKind::Bulleted,
                                        items: vec![
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("Sub point 1")),
                                                sublist: None,
                                            },
                                            ListItem {
                                                content: InlineTag::Plaintext(String::from("Sub point 2")),
                                                sublist: None,
                                            },
                                        ],
                                    }),
                                },
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Phrase {
                            kind: None,
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from("Well, that went well. How about we insert an ")),
                                InlineTag::NoTextile(String::from("<a href=\"http://www.textism.com/\" title=\"watch out\">old-fashioned hypertext link</a>")),
                                InlineTag::Plaintext(String::from("? Will the quote marks in the tags get messed up? No!")),
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Link {
                            attributes: None,
                            title: Some(String::from("optional title")),
                            url: String::from("http://www.textism.com"),
                            content: Box::new(
                                InlineTag::Plaintext(String::from("This is a link"))
                            )
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Phrase {
                            kind: None,
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from("table{border:1px solid black}.")),
                                InlineTag::LineBreak,
                                InlineTag::Plaintext(String::from("|")),
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Emphasis),
                                    attributes: None,
                                    content: vec![
                                        InlineTag::Plaintext(String::from(". this|"))
                                    ]
                                },
                                InlineTag::Plaintext(String::from(". is|")),
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Emphasis),
                                    attributes: None,
                                    content: vec![
                                        InlineTag::Plaintext(String::from(". a|"))
                                    ]
                                },
                                InlineTag::Plaintext(String::from(". header|")),
                                InlineTag::LineBreak,
                                InlineTag::Plaintext(String::from("<{background:gray}. |\\2. this is|{background:red;width:200px}. a|")),
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Superscript),
                                    attributes: None,
                                    content: vec![
                                        InlineTag::Plaintext(String::from("<>{height:200px}. row|")),
                                        InlineTag::LineBreak,
                                        InlineTag::Plaintext(String::from("|this|<>{padding:10px}. is|"))
                                    ]
                                },
                                InlineTag::Plaintext(String::from(". another|(bob#bob). row|"))
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Plaintext(String::from("An image:"))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Image {
                            attributes: None,
                            align: None,
                            url: String::from("/textist.gif"),
                            alt: Some(String::from("optional alt text"))
                        }
                    },
                    BlockTag::List{
                        indent: None,
                        align: None,
                        attributes: None,
                        content: List {
                            kind: ListKind::Numeric,
                            items: vec![
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("Librarians rule")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("Yes they do")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("But you knew that")),
                                    sublist: None,
                                },
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Phrase {
                            kind: None,
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from("Some more text of dubious character. Here is a noisome string of ")),
                                InlineTag::Acronym {
                                    title: None,
                                    content: String::from("CAPITAL")
                                },
                                InlineTag::Plaintext(String::from(" letters. Here is something we want to ")),
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Emphasis),
                                    attributes: None,
                                    content: vec![
                                        InlineTag::Plaintext(String::from("emphasize"))
                                    ]
                                },
                                InlineTag::Plaintext(String::from(".")),
                                InlineTag::LineBreak,
                                InlineTag::Plaintext(String::from("That was a linebreak. And something to indicate ")),
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Strong),
                                    attributes: None,
                                    content: vec![
                                        InlineTag::Plaintext(String::from("strength"))
                                    ]
                                },
                                InlineTag::Plaintext(String::from(". Of course I could use <em>my own ")),
                                InlineTag::Acronym {
                                    title: None,
                                    content: String::from("HTML")
                                },
                                InlineTag::Plaintext(String::from(" tags</em> if I <strong>felt</strong> like it."))
                            ]
                        }
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Header(3),
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Plaintext(String::from("Coding"))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Phrase {
                            kind: None,
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from("This ")),
                                InlineTag::Code(String::from("is some code, \"isn't it\"")),
                                InlineTag::Plaintext(String::from(". Watch those quote marks! Now for some preformatted text:"))
                            ]
                        }
                    },
                    BlockTag::Preformatted {
                        kind: BlockKind::BlockCode,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: String::from("$text = str_replace(\"<p>%::%</p>\",\"\",$text);\n$text = str_replace(\"%::%</p>\",\"\",$text);\n$text = str_replace(\"%::%\",\"\",$text);")
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Plaintext(String::from("This isn't code."))
                    },
                    BlockTag::Basic {
                        kind: BlockKind::Paragraph,
                        indent: None,
                        align: None,
                        attributes: None,
                        content: InlineTag::Plaintext(String::from("So you see, my friends:"))
                    },
                    BlockTag::List {
                        indent: None,
                        align: None,
                        attributes: None,
                        content: List {
                            kind: ListKind::Bulleted,
                            items: vec![
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("The time is now")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("The time is not later")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("The time is not yesterday")),
                                    sublist: None,
                                },
                                ListItem {
                                    content: InlineTag::Plaintext(String::from("We must act")),
                                    sublist: None,
                                },
                            ]
                        }
                    }
                }
            ])
        ))
    );
                ])
            ))
        );
    }
}
*/

A src/parse/no_textile.rs => src/parse/no_textile.rs +70 -0
@@ 0,0 1,70 @@
use crate::structs::InlineTag;
use nom::{
    branch::alt,
    bytes::complete::tag,
    combinator::{fail, value},
    multi::fold_many0,
    sequence::delimited,
    IResult,
};

pub fn no_textile(input: &str) -> IResult<&str, InlineTag> {
    let (rest, content) = delimited(
        tag("=="),
        fold_many0(
            alt((
                value("\\", tag("\\\\")),
                value("==", tag("\\==")),
                no_textile_content,
            )),
            String::new,
            |mut acc, s| {
                acc.push_str(s);
                acc
            },
        ),
        tag("=="),
    )(input)?;
    Ok((rest, InlineTag::NoTextile(content)))
}

fn no_textile_content(input: &str) -> IResult<&str, &str> {
    let res: IResult<&str, &str> = alt((tag("=="), tag("\\")))(input);
    if let Err(_) = res {
        if input != "" {
            return Ok((&input[1..], &input[0..1]));
        }
    }
    fail(input)
}

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

    #[test]
    fn no_textile_basic() {
        let input = "==hello *world!*==";
        let result = no_textile(input);
        assert_eq!(
            result,
            Ok(("", InlineTag::NoTextile(String::from("hello *world!*",))))
        );
    }

    #[test]
    fn no_textile_with_equals() {
        let input =
            "==This is two equals signs: \\== here are two more: \\====";
        let result = no_textile(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::NoTextile(String::from(
                    "This is two equals signs: == here are two more: ==",
                ))
            ))
        );
    }
}

A src/parse/phrase.rs => src/parse/phrase.rs +510 -0
@@ 0,0 1,510 @@
use crate::parse::block::strip_flatiron_extended;
use crate::parse::{acronym, attributes, from_num};
use crate::structs::{InlineTag, PhraseKind};
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, take_while1},
    character::complete::{char, line_ending, none_of},
    combinator::{complete, fail, map_res, opt, value},
    sequence::delimited,
    IResult,
};

pub fn phrase(input: &str) -> IResult<&str, InlineTag> {
    let mut input_string = input.to_string();
    if let Ok((_, stripped)) = complete(strip_flatiron_extended)(input) {
        input_string = stripped;
    }
    let mut input = input_string.as_str();

    let mut content: Vec<InlineTag> = Vec::new();

    let mut i = 0;
    let mut preceding_space = true;
    loop {
        // check if end of phrase has been reached
        if &input[i..] == "" {
            if i > 0 {
                content.push(InlineTag::Plaintext(String::from(&input[..i])));
            }
            match content.len() {
                0 => {
                    // return empty plaintext rather than phrase
                    return Ok(("", InlineTag::Plaintext(String::from(""))));
                }
                1 => {
                    // return the only tag
                    return Ok(("", content.remove(0)));
                }
                _ => (),
            }
            return Ok((
                "",
                InlineTag::Phrase {
                    attributes: None,
                    kind: None,
                    content,
                },
            ));
        }

        let mut matched = None;
        if preceding_space {
            // only match acronym if preceded by a space
            if let Ok((rest, tag)) = acronym::acronym(&input[i..]) {
                matched = Some((rest, tag))
            }
        }
        if matched.is_none() {
            for parser in InlineTag::parsers() {
                if let Ok((rest, tag)) = parser(&input[i..]) {
                    matched = Some((rest, tag));
                    break;
                }
            }
        }
        if matched.is_none() {
            for phrase_kind in PhraseKind::list() {
                if let Ok((rest, tag)) =
                    tagged_phrase(*phrase_kind)(&input[i..])
                {
                    matched = Some((rest, tag));
                    break;
                }
            }
        }

        if let Some((rest, tag)) = matched {
            if i > 0 {
                content.push(InlineTag::Plaintext(String::from(&input[..i])));
            }
            input = rest;
            i = 0;
            content.push(tag);
            preceding_space = true;
        } else {
            let c = input.chars().nth(i).unwrap();
            preceding_space = !c.is_alphanumeric();
            i += 1;
        }
    }
}

fn tagged_phrase(
    kind: PhraseKind,
) -> impl FnMut(&str) -> IResult<&str, InlineTag> {
    move |mut input: &str| {
        // match opening delimiter
        let (rest, _delimiter) = kind.delimiter(input)?;
        input = rest;
        // only match attributes if phrase is tagged
        let (rest, attributes) = opt(attributes::attributes)(input)?;
        input = rest;

        let mut content: Vec<InlineTag> = Vec::new();

        let mut i = 0;
        let mut preceding_space = true;
        loop {
            if i >= input.len() {
                return fail(input);
            }
            // check if end of phrase has been reached
            if let Ok((rest, _delimiter)) = kind.delimiter(&input[i..]) {
                if i > 0 {
                    content
                        .push(InlineTag::Plaintext(String::from(&input[..i])));
                }
                return Ok((
                    rest,
                    InlineTag::Phrase {
                        attributes,
                        kind: Some(kind),
                        content,
                    },
                ));
            }

            let mut matched = None;
            if preceding_space {
                // only match acronym if preceded by a space
                if let Ok((rest, tag)) = acronym::acronym(&input[i..]) {
                    matched = Some((rest, tag))
                }
            }
            if matched.is_none() {
                for parser in InlineTag::parsers() {
                    if let Ok((rest, tag)) = parser(&input[i..]) {
                        matched = Some((rest, tag));
                        break;
                    }
                }
            }
            if matched.is_none() {
                for phrase_kind in PhraseKind::list() {
                    if let Ok((rest, tag)) =
                        tagged_phrase(*phrase_kind)(&input[i..])
                    {
                        matched = Some((rest, tag));
                        break;
                    }
                }
            }
            if let Some((rest, tag)) = matched {
                if i > 0 {
                    content
                        .push(InlineTag::Plaintext(String::from(&input[..i])));
                }
                input = rest;
                i = 0;
                content.push(tag);
                preceding_space = true;
            } else {
                let c = input.chars().nth(i).unwrap();
                preceding_space = !c.is_alphanumeric();
                i += 1;
            }
        }
    }
}

pub fn line_break(input: &str) -> IResult<&str, InlineTag> {
    let (rest, _) = line_ending(input)?;
    Ok((rest, InlineTag::LineBreak))
}

pub fn code(input: &str) -> IResult<&str, InlineTag> {
    let (rest, content) = delimited(
        char('@'),
        escaped_transform(
            none_of("\\@"),
            '\\',
            alt((value("\\", char('\\')), value("@", char('@')))),
        ),
        char('@'),
    )(input)?;
    Ok((rest, InlineTag::Code(content)))
}

pub fn footnote_ref(input: &str) -> IResult<&str, InlineTag> {
    let (rest, n) = delimited(
        char('['),
        map_res(take_while1(|c: char| c.is_ascii_digit()), from_num),
        char(']'),
    )(input)?;
    Ok((rest, InlineTag::FootnoteRef(n)))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::structs::Attributes;

    #[test]
    fn empty_phrase() {
        let input = "";
        let result = phrase(input);
        assert_eq!(result, Ok(("", InlineTag::Plaintext(String::from("")))));
    }

    #[test]
    fn plaintext_phrase() {
        let input = "I am french!!";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok(("", InlineTag::Plaintext(String::from("I am french!!"))))
        );
    }

    #[test]
    fn phrase_with_acronym() {
        let input = "I am in the ACLU(American Civil Liberties Union), I know how this works";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("I am in the ")),
                        InlineTag::Acronym {
                            title: Some(String::from(
                                "American Civil Liberties Union"
                            )),
                            content: String::from("ACLU")
                        },
                        InlineTag::Plaintext(String::from(
                            ", I know how this works"
                        ))
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_image() {
        let input = "Textist: !/common/textist.gif(Textist)!";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("Textist: ")),
                        InlineTag::Image {
                            attributes: None,
                            align: None,
                            url: String::from("/common/textist.gif"),
                            alt: Some(String::from("Textist")),
                        }
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_notextile() {
        let input = "Here is ==no **textile**==";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("Here is ")),
                        InlineTag::NoTextile(String::from("no **textile**"))
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_italic() {
        let input = "RIP__scrip__";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Acronym {
                            title: None,
                            content: String::from("RIP")
                        },
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Italic),
                            attributes: None,
                            content: vec![InlineTag::Plaintext(String::from(
                                "scrip"
                            ))]
                        }
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_bold() {
        let input = "This is **bold**";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("This is ")),
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Bold),
                            attributes: None,
                            content: vec![InlineTag::Plaintext(String::from(
                                "bold"
                            ))]
                        }
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_bold_and_italic() {
        let input = "This is **bold. RIP__scrip__**";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("This is ")),
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Bold),
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from("bold. ")),
                                InlineTag::Acronym {
                                    title: None,
                                    content: String::from("RIP")
                                },
                                InlineTag::Phrase {
                                    kind: Some(PhraseKind::Italic),
                                    attributes: None,
                                    content: vec![InlineTag::Plaintext(
                                        String::from("scrip")
                                    )]
                                }
                            ]
                        }
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_bold_and_notextile() {
        let input = "This is **bold. ==no **textile**!!==**";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("This is ")),
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Bold),
                            attributes: None,
                            content: vec![
                                InlineTag::Plaintext(String::from("bold. ")),
                                InlineTag::NoTextile(String::from(
                                    "no **textile**!!"
                                ))
                            ]
                        }
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_secret_strong() {
        let input = "This is **bold. or is it?";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("This is ")),
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Strong),
                            attributes: None,
                            content: vec![]
                        },
                        InlineTag::Plaintext(String::from("bold. or is it?"))
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_hyphens() {
        let input = "You yellow-bellied hot-blooded rattlesnake! Who do you think you are?? I spit at you!!";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Plaintext(String::from(
                    "You yellow-bellied hot-blooded rattlesnake! Who do you think you are?? I spit at you!!"
                ))
            ))
        );
    }

    #[test]
    fn phrase_with_broken_nesting() {
        let input = "This is **bold. No _you hang up first**";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("This is ")),
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Bold),
                            attributes: None,
                            content: vec![InlineTag::Plaintext(String::from(
                                "bold. No _you hang up first"
                            )),]
                        }
                    ]
                }
            ))
        );
    }

    #[test]
    fn phrase_with_attributes() {
        let input = "Look, it's %{color:red}red%";
        let result = phrase(input);
        assert_eq!(
            result,
            Ok((
                "",
                InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from("Look, it's ")),
                        InlineTag::Phrase {
                            kind: Some(PhraseKind::Span),
                            attributes: Some(Attributes {
                                class: None,
                                id: None,
                                style: Some(String::from("color:red")),
                                language: None,
                            }),
                            content: vec![InlineTag::Plaintext(String::from(
                                "red"
                            )),]
                        }
                    ]
                }
            ))
        );
    }
}

A src/parse/table.rs => src/parse/table.rs +10 -0
@@ 0,0 1,10 @@
use crate::structs::VerticalAlign;
use nom::{branch::alt, bytes::complete::tag, combinator::value, IResult};

fn vertical_alignment(input: &str) -> IResult<&str, VerticalAlign> {
    alt((
        value(VerticalAlign::Top, tag("^")),
        value(VerticalAlign::Middle, tag("-")),
        value(VerticalAlign::Bottom, tag("~")),
    ))(input)
}

A src/structs.rs => src/structs.rs +129 -0
@@ 0,0 1,129 @@
#[derive(Debug, PartialEq)]
pub struct Textile(pub Vec<BlockTag>);

#[derive(Debug, PartialEq)]
pub enum BlockTag {
    Basic {
        kind: BlockKind,
        indent: Option<Indent>,
        align: Option<Align>,
        attributes: Option<Attributes>,
        content: InlineTag,
    },
    Preformatted {
        kind: BlockKind,
        indent: Option<Indent>,
        align: Option<Align>,
        attributes: Option<Attributes>,
        content: String,
    },
    List {
        indent: Option<Indent>,
        align: Option<Align>,
        attributes: Option<Attributes>,
        content: List,
    },
    NoTextile(String),
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum BlockKind {
    Paragraph,
    NoTextile,
    BlockQuote,
    BlockCode,
    Preformatted,
    Header(usize),
    Footnote(usize),
}

#[derive(Debug, PartialEq)]
pub struct List {
    pub kind: ListKind,
    pub items: Vec<ListItem>,
}

#[derive(Debug, PartialEq)]
pub struct ListItem {
    pub content: InlineTag,
    pub sublist: Option<List>,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum ListKind {
    Numeric,
    Bulleted,
}

#[derive(Debug, PartialEq)]
pub struct Indent {
    pub left: usize,
    pub right: usize,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Align {
    Left,
    Right,
    Center,
    Justify,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum VerticalAlign {
    Top,
    Middle,
    Bottom,
}

#[derive(Debug, PartialEq)]
pub struct Attributes {
    pub class: Option<String>,
    pub id: Option<String>,
    pub style: Option<String>,
    pub language: Option<String>,
}

#[derive(Debug, PartialEq)]
pub enum InlineTag {
    Plaintext(String),
    NoTextile(String),
    Code(String),
    FootnoteRef(usize),
    LineBreak,
    Image {
        attributes: Option<Attributes>,
        align: Option<Align>,
        url: String,
        alt: Option<String>,
    },
    Acronym {
        title: Option<String>,
        content: String,
    },
    Link {
        attributes: Option<Attributes>,
        title: Option<String>,
        url: String,
        content: Box<InlineTag>,
    },
    Phrase {
        kind: Option<PhraseKind>,
        attributes: Option<Attributes>,
        content: Vec<InlineTag>,
    },
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub enum PhraseKind {
    Italic,
    Bold,
    Emphasis,
    Strong,
    Citation,
    Deleted,
    Inserted,
    Superscript,
    Subscript,
    Span,
}

D tests/attributes.rs => tests/attributes.rs +0 -73
@@ 1,73 0,0 @@
use flatiron::parse::*;

#[test]
fn attributes_csl() {
    let input = "(class#id){style}[language]";
    let result = attributes(input);
    assert_eq!(
        result,
        Ok((
            "",
            Attributes {
                class: Some(String::from("class")),
                id: Some(String::from("id")),
                style: Some(String::from("style")),
                language: Some(String::from("language")),
            }
        ))
    );
}

#[test]
fn attributes_scl() {
    let input = "{style}(class#id)[language]";
    let result = attributes(input);
    assert_eq!(
        result,
        Ok((
            "",
            Attributes {
                class: Some(String::from("class")),
                id: Some(String::from("id")),
                style: Some(String::from("style")),
                language: Some(String::from("language")),
            }
        ))
    );
}

#[test]
fn attributes_lcs() {
    let input = "[language]{style}(class#id)";
    let result = attributes(input);
    assert_eq!(
        result,
        Ok((
            "",
            Attributes {
                class: Some(String::from("class")),
                id: Some(String::from("id")),
                style: Some(String::from("style")),
                language: Some(String::from("language")),
            }
        ))
    );
}

#[test]
fn attributes_class() {
    let input = "(class)";
    let result = attributes(input);
    assert_eq!(
        result,
        Ok((
            "",
            Attributes {
                class: Some(String::from("class")),
                id: None,
                style: None,
                language: None,
            }
        ))
    );
}

D tests/block.rs => tests/block.rs +0 -275
@@ 1,275 0,0 @@
use flatiron::parse::*;

#[test]
fn block_basic() {
    let input = "Untagged paragraph";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Paragraph,
                indent: None,
                align: None,
                attributes: None,
                content: InlineTag::Plaintext(String::from(
                    "Untagged paragraph"
                ))
            }
        ))
    );
}

#[test]
fn explicit_paragraph() {
    let input = "p. Tagged paragraph";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Paragraph,
                indent: None,
                align: None,
                attributes: None,
                content: InlineTag::Plaintext(String::from("Tagged paragraph"))
            }
        ))
    );
}

#[test]
fn block_pre() {
    let input = "pre. Preformatted _paragraph_";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Preformatted {
                kind: BlockKind::Preformatted,
                indent: None,
                align: None,
                attributes: None,
                content: String::from("Preformatted _paragraph_")
            }
        ))
    );
}

#[test]
fn block_notextile() {
    let input = "notextile. **Notextile** paragraph";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::NoTextile(String::from("**Notextile** paragraph"))
        ))
    );
}

#[test]
fn extended_block() {
    let input =
        "p.. Extended paragraph\n\nWhere will she go?\n\np. Hell if I know.";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "p. Hell if I know.",
            BlockTag::Basic {
                kind: BlockKind::Paragraph,
                indent: None,
                align: None,
                attributes: None,
                content: InlineTag::Phrase {
                    kind: None,
                    attributes: None,
                    content: vec![
                        InlineTag::Plaintext(String::from(
                            "Extended paragraph"
                        )),
                        InlineTag::LineBreak,
                        InlineTag::LineBreak,
                        InlineTag::Plaintext(String::from(
                            "Where will she go?"
                        ))
                    ]
                }
            }
        ))
    );
}

#[test]
fn block_with_attributes() {
    let input = "h2(class){color:green}. This is a title";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Header(2),
                indent: None,
                align: None,
                attributes: Some(Attributes {
                    class: Some(String::from("class")),
                    id: None,
                    style: Some(String::from("color:green")),
                    language: None,
                }),
                content: InlineTag::Plaintext(String::from("This is a title"))
            }
        ))
    );
}

#[test]
fn centered_block() {
    let input = "h2=. This is a title";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Header(2),
                indent: None,
                align: Some(Align::Center),
                attributes: None,
                content: InlineTag::Plaintext(String::from("This is a title"))
            }
        ))
    );
}

#[test]
fn indented_block() {
    let input = "h2(). This is a title";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Header(2),
                indent: Some(Indent { left: 1, right: 1 }),
                align: None,
                attributes: None,
                content: InlineTag::Plaintext(String::from("This is a title"))
            }
        ))
    );
}

#[test]
fn indented_aligned_block() {
    let input = "p))>. I am a fish!";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Paragraph,
                indent: Some(Indent { left: 0, right: 2 }),
                align: Some(Align::Right),
                attributes: None,
                content: InlineTag::Plaintext(String::from("I am a fish!"))
            }
        ))
    );
}

#[test]
fn aligned_indented_block() {
    let input = "p>)). I am a transmitter!";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Paragraph,
                indent: Some(Indent { left: 0, right: 2 }),
                align: Some(Align::Right),
                attributes: None,
                content: InlineTag::Plaintext(String::from(
                    "I am a transmitter!"
                ))
            }
        ))
    );
}

#[test]
fn block_with_attributes_indent_align() {
    let input = "p(greeting){color:green}[fr]()>. Salut!";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Basic {
                kind: BlockKind::Paragraph,
                indent: Some(Indent { left: 1, right: 1 }),
                align: Some(Align::Right),
                attributes: Some(Attributes {
                    class: Some(String::from("greeting")),
                    id: None,
                    style: Some(String::from("color:green")),
                    language: Some(String::from("fr")),
                }),
                content: InlineTag::Plaintext(String::from("Salut!"))
            }
        ))
    );
}

#[test]
fn flatiron_blockcode() {
    let input = "bc. This is supposed to be a block of textile code.
|
|bq. Textile should really have a closing delimiter for blocks.
|--Autumn
|
|Here's some textile code within the textile code:
|
|bc.
||bq. This is getting out of hand... now there are two of them!
||--Nute Gunray
|
|Now some more text within the code block";
    let result = block(input);
    assert_eq!(
        result,
        Ok((
            "",
            BlockTag::Preformatted {
                kind: BlockKind::BlockCode,
                indent: None,
                align: None,
                attributes: None,
                content: String::from(
                    "This is supposed to be a block of textile code.

bq. Textile should really have a closing delimiter for blocks.
--Autumn

Here's some textile code within the textile code:

bc.
|bq. This is getting out of hand... now there are two of them!
|--Nute Gunray

Now some more text within the code block"
                )
            }
        ))
    );
}

D tests/link.rs => tests/link.rs +0 -107
@@ 1,107 0,0 @@
use flatiron::parse::*;

#[test]
fn link_basic() {
    let input = "\"a link\":https://github.com/autumnull/flatiron";
    let result = link(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Link {
                attributes: None,
                title: None,
                url: String::from("https://github.com/autumnull/flatiron"),
                content: Box::new(InlineTag::Plaintext(String::from("a link")))
            }
        ))
    );
}

#[test]
fn link_attributes() {
    let input = "\"(class)a link\":https://github.com/autumnull/flatiron";
    let result = link(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Link {
                attributes: Some(Attributes {
                    class: Some(String::from("class")),
                    id: None,
                    style: None,
                    language: None,
                }),
                title: None,
                url: String::from("https://github.com/autumnull/flatiron"),
                content: Box::new(InlineTag::Plaintext(String::from("a link")))
            }
        ))
    );
}

#[test]
fn link_attributes_title() {
    let input =
        "\"(class)a link (title)\":https://github.com/autumnull/flatiron";
    let result = link(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Link {
                attributes: Some(Attributes {
                    class: Some(String::from("class")),
                    id: None,
                    style: None,
                    language: None,
                }),
                title: Some(String::from("title")),
                url: String::from("https://github.com/autumnull/flatiron"),
                content: Box::new(InlineTag::Plaintext(String::from("a link")))
            }
        ))
    );
}

#[test]
fn link_escaped() {
    let input =
        "\"Earvin \\\"Magic\\\" Johnson \\(Basketball Player\\)\":https://en.wikipedia.org/wiki/Magic_Johnson";
    let result = link(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::Link {
                attributes: None,
                title: None,
                url: String::from(
                    "https://en.wikipedia.org/wiki/Magic_Johnson"
                ),
                content: Box::new(InlineTag::Plaintext(String::from(
                    "Earvin \"Magic\" Johnson (Basketball Player)"
                )))
            }
        ))
    );
}

#[test]
fn link_parenthesized() {
    let input = "\"this\":(https://github.com/autumnull/flatiron)?";
    let result = link(input);
    assert_eq!(
        result,
        Ok((
            "?",
            InlineTag::Link {
                attributes: None,
                title: None,
                url: String::from("https://github.com/autumnull/flatiron"),
                content: Box::new(InlineTag::Plaintext(String::from("this")))
            }
        ))
    );
}

D tests/notextile.rs => tests/notextile.rs +0 -26
@@ 1,26 0,0 @@
use flatiron::parse::*;

#[test]
fn no_textile_basic() {
    let input = "==hello *world!*==";
    let result = no_textile(input);
    assert_eq!(
        result,
        Ok(("", InlineTag::NoTextile(String::from("hello *world!*",))))
    );
}

#[test]
fn no_textile_with_equals() {
    let input = "==This is two equals signs: \\== here are two more: \\====";
    let result = no_textile(input);
    assert_eq!(
        result,
        Ok((
            "",
            InlineTag::NoTextile(String::from(
                "This is two equals signs: == here are two more: ==",
            ))
        ))
    );
}

D tests/phrase.rs => tests/phrase.rs +0 -309
@@ 1,309 0,0 @@
use flatiron::parse::*;

#[test]
fn empty_phrase() {
    let input = "";
    let result = phrase(input);
    assert_eq!(result, Ok(("", InlineTag::Plaintext(String::from("")))));
}