///|
/// AST types
pub(all) enum Inline {
Plain(String)
Strong(Array[Inline])
Emph(Array[Inline])
Code(String)
Link(Array[Inline], String)
Image(String, String)
SoftBreak
HardBreak
InlineMath(String)
FootnoteRef(String)
} derive(Eq, Debug)
///|
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(all) struct TableRow {
cells : Array[Array[Inline]]
} derive(Eq, Debug)
///|
pub(all) struct MdListItem {
checked : Bool?
content : Array[Inline]
children : Array[Block]
} derive(Eq, Debug)
///|
/// 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.char('\\')
.skip(@talc.char('\n'))
.attempt()
.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(')'))
///|
fn link() -> InlineParser {
@talc.char('[')
.then(delim(']').then(inline()).some())
.skip(@talc.char(']'))
.bind(text => link_url.map(url => Link(text, url)))
.attempt()
}
///|
let image : InlineParser = @talc.char('!')
.then(@talc.char('['))
.then(@talc.many_chars(@talc.none_of("]")))
.skip(@talc.char(']'))
.bind(alt => link_url.map(url => Image(alt, url)))
///|
fn footnote_ref() -> InlineParser {
@talc.char('[')
.then(@talc.char('^'))
.then(@talc.many_chars1(@talc.none_of("]")))
.skip(@talc.char(']'))
.attempt()
.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(),
link(),
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))
}
///|
/// 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.bind(_ => {
@talc.Parser::new(input => {
@talc.many_chars(@talc.not_newline)
.run(input)
.bind(pair => {
let (text, rest) = pair
line_end
.run(rest)
.bind(pair2 => {
let (_, after_nl) = pair2
parse_inlines(text).map(inlines => {
(Heading(hashes.length(), inlines), after_nl)
})
})
})
})
})
})
///|
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)
///|
fn blockquote() -> BlockParser {
quoted_line
.some()
.bind(lines => {
@talc.Parser::new(input => {
block()
.some()
.parse(@talc.Input::new(lines.join("")))
.map(blocks => (Blockquote(blocks), input))
})
})
}
///|
/// 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()
}
@talc.Parser::new(input => {
parse_inlines(text).map(inlines => (Paragraph(inlines), input))
})
})
///|
/// Table
let table_cell : @talc.StringParser[Array[Inline]] = @talc.many_chars(
@talc.none_of("|\n"),
).bind(text => {
let trimmed = text.trim()
@talc.Parser::new(input => {
parse_inlines(trimmed.to_owned()).map(inlines => (inlines, input))
})
})
///|
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(_ => ())
///|
pub fn table() -> BlockParser {
@talc.Parser::new(input => {
match table_row().run(input) {
Ok((header, after_header)) =>
match table_delimiter_row.run(after_header) {
Ok((_, after_delim)) =>
table_row()
.many()
.run(after_delim)
.map(pair => {
let (rows, rest) = pair
(Table(header, rows), rest)
})
Err(_) =>
Err(
@talc.ParseError::message(
after_header, "expected table delimiter row",
).with_commit(),
)
}
Err(e) => Err(e)
}
})
}
///|
/// 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()
.bind(_ => {
task_marker()
.attempt()
.optional()
.bind(checked => {
@talc.Parser::new(input => {
match @talc.many_chars(@talc.not_newline).run(input) {
Ok((text, rest)) =>
match parse_inlines(text.trim().to_owned()) {
Ok(content) =>
match @talc.char('\n').run(rest) {
Ok((_, after_nl)) =>
sub_list(indent + 2)
.many()
.run(after_nl)
.map(pair => {
let (children, rest2) = pair
({ checked, content, children }, rest2)
})
Err(_) =>
sub_list(indent + 2)
.many()
.run(rest)
.map(pair => {
let (children, rest2) = pair
({ checked, content, children }, rest2)
})
}
Err(e) => Err(e)
}
Err(e) => Err(e)
}
})
})
})
}
///|
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 => {
@talc.Parser::new(input => {
parse_inlines(text.trim().to_owned()).map(inlines => {
(FootnoteDef(label, [Paragraph(inlines)]), input)
})
})
})
})
.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())