///|
/// AST types
pub(all) enum Inline {
Plain(String)
Strong(Array[Inline])
Emph(Array[Inline])
Code(String)
Image(String, String)
SoftBreak
HardBreak
InlineMath(String)
FootnoteRef(String)
} derive(Eq, Debug)
///|
pub extend Inline with Eq::{not_equal, equal}
///|
pub extend Inline with Debug::{to_repr}
///|
pub(all) enum Block {
Heading(Int, Array[Inline])
Paragraph(Array[Inline])
Blockquote(Array[Block])
UnorderedList(Array[MdListItem])
OrderedList(Array[MdListItem])
FencedCode(String?, String)
DisplayMath(String)
ThematicBreak
Table(TableRow, Array[TableRow])
FootnoteDef(String, Array[Block])
} derive(Eq, Debug)
///|
pub extend Block with Eq::{not_equal, equal}
///|
pub extend Block with Debug::{to_repr}
///|
pub(all) struct TableRow {
cells : Array[Array[Inline]]
} derive(Eq, Debug)
///|
pub extend TableRow with Eq::{not_equal, equal}
///|
pub extend TableRow with Debug::{to_repr}
///|
pub(all) struct MdListItem {
checked : Bool?
content : Array[Inline]
children : Array[Block]
} derive(Eq, Debug)
///|
pub extend MdListItem with Eq::{not_equal, equal}
///|
pub extend MdListItem with Debug::{to_repr}
///|
/// Inline parsers
pub let special_chars = "*_`[\\$"
///|
pub fn delim(c : Char) -> @talc.StringParser[Unit] {
@talc.char(c).not_followed_by("'\{c}'")
}
///|
pub type InlineParser = @talc.StringParser[Inline]
///|
pub type BlockParser = @talc.StringParser[Block]
///|
let hard_break : InlineParser = @talc.string("\\\n").map(_ => HardBreak)
///|
let soft_break : InlineParser = @talc.char('\n').map(_ => SoftBreak)
///|
let escaped : InlineParser = @talc.char('\\')
.then(@talc.one_of(special_chars))
.map(c => Plain(c.to_string()))
///|
let code_span : InlineParser = @talc.char('`')
.then(@talc.many_chars(@talc.none_of("`")))
.skip(@talc.char('`'))
.map(c => Code(c))
///|
let inline_math : InlineParser = @talc.char('$')
.then(@talc.many_chars(@talc.none_of("$")))
.skip(@talc.char('$'))
.attempt()
.map(c => InlineMath(c))
///|
let link_url : @talc.SParser = @talc.char('(')
.then(@talc.many_chars(@talc.none_of(")")))
.skip(@talc.char(')'))
///|
let image : InlineParser = @talc.string("![")
.then(@talc.many_chars(@talc.none_of("]")))
.skip(@talc.char(']'))
.bind(alt => link_url.map(url => Image(alt, url)))
///|
fn footnote_ref() -> InlineParser {
@talc.string("[^")
.then(@talc.many_chars1(@talc.none_of("]")))
.skip(@talc.char(']'))
.map(label => FootnoteRef(label))
}
///|
fn strong() -> InlineParser {
@talc.char('*')
.then(delim('*').then(inline()).some())
.skip(@talc.char('*'))
.map(c => Strong(c))
}
///|
fn emph() -> InlineParser {
@talc.char('_')
.then(delim('_').then(inline()).some())
.skip(@talc.char('_'))
.map(c => Emph(c))
}
///|
let plain_char : InlineParser = {
let non_special = @talc.none_of(special_chars + "\n")
non_special.bind(c => {
@talc.many_chars(non_special).map(rest => Plain("\{c}\{rest}"))
})
}
///|
let plain_bracket : InlineParser = @talc.one_of("[]").map(c => {
Plain(c.to_string())
})
///|
fn inline() -> InlineParser {
@talc.delay(() => {
@talc.choice([
hard_break,
escaped,
image,
footnote_ref(),
strong(),
emph(),
inline_math,
code_span,
soft_break,
plain_char,
plain_bracket,
])
})
}
///|
pub fn parse_inlines(text : String) -> Result[Array[Inline], @talc.ParseError] {
inline().many().parse(@talc.Input::new(text))
}
///|
/// Parses `text` as inline markdown, as a parser that consumes no input.
///
/// Lets the inline parser be composed in a talc chain, e.g.
/// `rest_of_line.bind(text => inlines(text.trim().to_owned()))`.
fn inlines(text : String) -> @talc.StringParser[Array[Inline]] {
@talc.Parser::new(input => {
parse_inlines(text).map(inlines => (inlines, input))
})
}
///|
/// Block parsers
let line_end : @talc.StringParser[Unit] = @talc.char('\n')
.map(_ => ())
.or(@talc.eof())
///|
let blank_line : @talc.StringParser[Unit] = {
let ws_no_nl = @talc.satisfy("non-newline whitespace", c => {
Char::is_whitespace(c) && c != '\n'
})
@talc.many_chars(ws_no_nl).skip(@talc.char('\n')).map(_ => ()).attempt()
}
///|
let thematic_break : BlockParser = @talc.string("---")
.then(@talc.char('-').many())
.skip(line_end)
.map(_ => ThematicBreak)
///|
let heading : BlockParser = @talc.char('#')
.some()
.bind(hashes => {
@talc.space.then(
@talc.line_content
.bind(text => line_end.then(inlines(text)))
.map(inlines => Heading(hashes.length(), inlines)),
)
})
///|
let rest_of_line : @talc.SParser = @talc.many_chars1(@talc.not_newline)
.bind(content => line_end.map(_ => content + "\n"))
.or(@talc.char('\n').map(_ => "\n"))
///|
let fenced_code : BlockParser = {
let not_nl = @talc.not_newline
@talc.string("```")
.then(@talc.many_chars1(not_nl).optional())
.skip(line_end)
.bind(lang => {
let terminator = @talc.string("```").skip(line_end)
rest_of_line
.many_until(terminator)
.map(lines => FencedCode(lang, lines.join("")))
})
}
///|
/// Quote
let quoted_line : @talc.SParser = @talc.char('>')
.then(@talc.space.optional())
.then(rest_of_line)
///|
/// Parses `text` as a block document, as a parser that consumes no input.
///
/// The parallel of [`inlines`](`inlines`) for block content; used by
/// `blockquote` to re-parse the joined quoted lines as a fresh document.
/// (Can't reuse `document` itself: that would be a definition cycle.)
fn blocks_of(text : String) -> @talc.StringParser[Array[Block]] {
@talc.Parser::new(input => {
blank_line
.many()
.then(blocks())
.skip(blank_line.many())
.skip(@talc.eof())
.run(@talc.Input::new(text))
.map(pair => {
let (blocks, _) = pair
(blocks, input)
})
})
}
///|
fn blockquote() -> BlockParser {
quoted_line
.some()
.bind(lines => blocks_of(lines.join("")).map(blocks => Blockquote(blocks)))
}
///|
/// Paragraph
let paragraph_line : @talc.SParser = blank_line
.not_followed_by("blank line")
.then(delim('#'))
.then(@talc.string("---").not_followed_by("\"---\""))
.then(@talc.string("```").not_followed_by("\"```\""))
.then(@talc.string("$$").not_followed_by("\"$$\""))
.then(delim('>'))
.then(@talc.char('-').then(@talc.space).not_followed_by("'- '"))
.then(@talc.char('+').then(@talc.space).not_followed_by("'+ '"))
.then(delim('|'))
.then(rest_of_line)
///|
let paragraph : BlockParser = paragraph_line
.some()
.bind(lines => {
let joined = lines.join("")
let text = match () {
_ if joined.has_suffix("\\\n") => joined.trim_start().to_owned()
_ => joined.trim().to_owned()
}
inlines(text).map(inlines => Paragraph(inlines))
})
///|
/// Table
let table_cell : @talc.StringParser[Array[Inline]] = @talc.many_chars(
@talc.none_of("|\n"),
).bind(text => inlines(text.trim().to_owned()))
///|
fn table_row() -> @talc.StringParser[TableRow] {
@talc.char('|')
.then(table_cell)
.bind(first => {
@talc.char('|')
.then(line_end.not_followed_by("trailing pipe"))
.then(table_cell)
.attempt()
.many()
.map(rest => {
let all = Array::new(capacity=rest.length() + 1)
all.push(first)
all.append(rest)
TableRow::{ cells: all }
})
})
.skip(@talc.char('|').optional())
.skip(line_end)
}
///|
let table_delimiter_cell : @talc.StringParser[Unit] = @talc.space
.many()
.then(@talc.char(':').optional())
.then(@talc.char('-').some())
.then(@talc.char(':').optional())
.skip(@talc.space.many())
.map(_ => ())
///|
let table_delimiter_row : @talc.StringParser[Unit] = @talc.char('|')
.then(table_delimiter_cell)
.then(@talc.char('|').then(table_delimiter_cell).attempt().many())
.skip(@talc.char('|').optional())
.skip(line_end)
.map(_ => ())
///|
/// A table delimiter row, failing with a clear message at the row start
/// when the header row is not followed by `| --- | --- |`-style markers.
let table_delimiter : @talc.StringParser[Unit] = @talc.Parser::new(input => {
table_delimiter_row
.run(input)
.map_err(_ => {
@talc.ParseError::message(input, "expected table delimiter row").with_commit()
})
})
///|
pub fn table() -> BlockParser {
table_row().bind(header => {
table_delimiter.then(table_row().many()).map(rows => Table(header, rows))
})
}
///|
/// Lists
fn task_marker() -> @talc.StringParser[Bool] {
@talc.choice([
@talc.string("[ ]").map(_ => false),
@talc.string("[x]").map(_ => true),
])
}
///|
fn list_item(marker : Char, indent : Int) -> @talc.StringParser[MdListItem] {
let istr = String::repeat(" ", indent)
@talc.string(istr)
.then(@talc.char(marker))
.skip(@talc.space)
.attempt()
.then(task_marker().attempt().optional())
.bind(checked => list_item_body(checked, indent))
}
///|
/// Parses the text of a list item and its sub-lists.
fn list_item_body(
checked : Bool?,
indent : Int,
) -> @talc.StringParser[MdListItem] {
@talc.line_content
.bind(text => inlines(text.trim().to_owned()))
.skip(@talc.char('\n').optional())
.bind(content => {
sub_list(indent + 2).many().map(children => { checked, content, children })
})
}
///|
fn unordered_list_at(indent : Int) -> BlockParser {
list_item('-', indent).some().map(items => UnorderedList(items))
}
///|
fn ordered_list_at(indent : Int) -> BlockParser {
list_item('+', indent).some().map(items => OrderedList(items))
}
///|
fn sub_list(indent : Int) -> BlockParser {
@talc.choice([unordered_list_at(indent), ordered_list_at(indent)])
}
///|
let unordered_list : BlockParser = unordered_list_at(0)
///|
let ordered_list : BlockParser = ordered_list_at(0)
///|
/// Footnote definition
let footnote_def : BlockParser = @talc.char('[')
.then(@talc.char('^'))
.then(@talc.many_chars1(@talc.none_of("]")))
.skip(@talc.string("]:"))
.skip(@talc.space)
.bind(label => {
rest_of_line.bind(text => {
inlines(text.trim().to_owned()).map(inlines => {
FootnoteDef(label, [Paragraph(inlines)])
})
})
})
.attempt()
///|
/// Display math block
let display_math_block : BlockParser = @talc.string("$$")
.bind(_ => {
@talc.choice([
@talc.char('\n').bind(_ => {
let terminator = @talc.string("$$").skip(@talc.char('\n').optional())
rest_of_line
.many_until(terminator)
.map(lines => DisplayMath(lines.join("").trim_end().to_owned()))
}),
@talc.many_chars(@talc.none_of("$"))
.skip(@talc.string("$$"))
.skip(@talc.char('\n').optional())
.map(content => DisplayMath(content.trim().to_owned())),
])
})
.attempt()
///|
/// Document
fn block() -> BlockParser {
@talc.choice([
thematic_break,
heading,
fenced_code,
display_math_block,
blockquote(),
footnote_def,
table(),
unordered_list,
ordered_list,
paragraph,
])
}
///|
fn blocks() -> @talc.StringParser[Array[Block]] {
block()
.bind(head => {
blank_line
.many()
.then(block())
.attempt()
.many()
.map(tail => Array::new(capacity=tail.length() + 1)..append([head, ..tail]))
})
.or(@talc.pure([]))
}
///|
pub let document : @talc.StringParser[Array[Block]] = blank_line
.many()
.then(blocks())
.skip(blank_line.many())
.skip(@talc.eof())