///|
/// A Markdown AST node shaped to serialize as mdast-compatible JSON.
pub(all) enum AstNode {
AstRoot(Array[AstNode])
AstParagraph(Array[AstNode])
AstHeading(Int, Array[AstNode])
AstThematicBreak
AstBlockquote(Array[AstNode])
AstList(Bool, Int?, Bool, Array[AstNode])
AstListItem(Bool, Bool?, Array[AstNode])
AstCode(String, String?, String?)
AstHtml(String)
AstText(String)
AstEmphasis(Array[AstNode])
AstStrong(Array[AstNode])
AstInlineCode(String)
AstBreak
AstLink(String, String?, Array[AstNode])
AstImage(String, String?, String)
AstDelete(Array[AstNode])
AstTable(Array[String?], Array[AstNode])
AstTableRow(Array[AstNode])
AstTableCell(String?, Array[AstNode])
AstTableSection(Array[AstNode])
AstExtension(String, Array[Attr], Array[AstNode])
}
///|
/// An error raised when an event stream cannot form a balanced AST.
pub(all) suberror AstError {
/// An `Exit` appeared with no matching open element.
UnexpectedExit(Tag)
/// An `Exit` closed a different tag than the currently open element.
MismatchedExit(Tag, Tag)
/// The event stream ended while an element was still open.
UnclosedTag(Tag)
}
///|
priv struct AstFrame {
tag : Tag?
attrs : Array[Attr]
children : Array[AstNode]
}
///|
/// A streaming AST builder sink.
pub struct Ast {
priv stack : Array[AstFrame]
priv mut finished : Bool
priv mut error : AstError?
}
///|
/// Creates an empty streaming AST builder.
pub fn Ast::Ast() -> Ast {
{ stack: [ast_root_frame()], finished: false, error: None }
}
///|
/// Returns a sink that builds the AST as events arrive.
pub fn Ast::sink(self : Ast) -> Sink {
Sink(fn(event) { self.accept(event) }, finish=fn() { self.finish() })
}
///|
/// Returns the AST root after the stream has finished.
pub fn Ast::result(self : Ast) -> AstNode raise AstError {
if !self.finished {
self.finish()
}
match self.error {
Some(err) => raise err
None => AstRoot(copy_ast_nodes(self.stack[0].children))
}
}
///|
fn ast_root_frame() -> AstFrame {
{ tag: None, attrs: [], children: [] }
}
///|
fn Ast::accept(self : Ast, event : Event) -> Unit {
guard !self.finished else { }
guard self.error is None else { }
match event {
Enter(el) =>
self.stack.push({ tag: Some(el.tag), attrs: el.attrs(), children: [] })
Exit(tag) => self.close(tag)
Text(text) => if text != "" { self.append(AstText(text)) }
SoftBreak => self.append(AstText("\n"))
HardBreak => self.append(AstBreak)
RawHtml(raw) => self.append(AstHtml(raw))
}
}
///|
fn Ast::append(self : Ast, node : AstNode) -> Unit {
self.stack[self.stack.length() - 1].children.push(node)
}
///|
fn Ast::close(self : Ast, tag : Tag) -> Unit {
guard self.stack.length() > 1 else { self.error = Some(UnexpectedExit(tag)) }
let frame = self.stack[self.stack.length() - 1]
match frame.tag {
Some(opened) if opened == tag => {
ignore(self.stack.pop())
self.append(ast_node_from_frame(frame))
}
Some(opened) => self.error = Some(MismatchedExit(opened, tag))
None => self.error = Some(UnexpectedExit(tag))
}
}
///|
fn Ast::finish(self : Ast) -> Unit {
guard !self.finished else { }
self.finished = true
guard self.error is None else { }
guard self.stack.length() != 1 else { }
let err = match self.stack[self.stack.length() - 1].tag {
Some(tag) => UnclosedTag(tag)
None => UnclosedTag(tag_name(""))
}
self.error = Some(err)
}
///|
fn ast_node_from_frame(frame : AstFrame) -> AstNode {
let tag = match frame.tag {
Some(tag) => tag.to_string()
None => ""
}
let children = copy_ast_nodes(frame.children)
match tag {
"cm:paragraph" => AstParagraph(children)
"cm:heading" => AstHeading(ast_heading_depth(frame.attrs), children)
"cm:thematic_break" => AstThematicBreak
"cm:code_block" =>
AstCode(
ast_text_value(children),
ast_optional_nonempty_attr(frame.attrs, "info"),
None,
)
"cm:block_quote" => AstBlockquote(children)
"cm:list" =>
AstList(
ast_bool_attr(frame.attrs, "ordered"),
ast_int_attr(frame.attrs, "start"),
ast_list_spread(children),
children,
)
"cm:item" => {
let spread = ast_list_item_spread(children)
AstListItem(
spread,
ast_checked_attr(frame.attrs),
ast_list_item_children(children),
)
}
"cm:code_span" => AstInlineCode(ast_text_value(children))
"cm:link" =>
AstLink(
ast_attr(frame.attrs, "destination", default=""),
ast_optional_attr(frame.attrs, "title"),
children,
)
"cm:image" =>
AstImage(
ast_attr(frame.attrs, "destination", default=""),
ast_optional_attr(frame.attrs, "title"),
ast_attr(frame.attrs, "alt", default=""),
)
"cm:emphasis" => AstEmphasis(children)
"cm:strong" => AstStrong(children)
"gfm:strikethrough" => AstDelete(children)
"gfm:table" => AstTable(ast_table_align(children), ast_table_rows(children))
"gfm:thead" | "gfm:tbody" => AstTableSection(children)
"gfm:tr" => AstTableRow(children)
"gfm:th" | "gfm:td" =>
AstTableCell(ast_optional_attr(frame.attrs, "align"), children)
_ => AstExtension(tag, copy_attrs_for_ast(frame.attrs), children)
}
}
///|
fn ast_heading_depth(attrs : Array[Attr]) -> Int {
let depth = ast_int_attr(attrs, "level").unwrap_or(1)
if depth < 1 {
1
} else if depth > 6 {
6
} else {
depth
}
}
///|
fn ast_attr(attrs : Array[Attr], name : String, default~ : String) -> String {
match ast_optional_attr(attrs, name) {
Some(value) => value
None => default
}
}
///|
fn ast_optional_attr(attrs : Array[Attr], name : String) -> String? {
for attr in attrs {
if attr.name().to_string() == name {
return Some(attr.value())
}
}
None
}
///|
fn ast_optional_nonempty_attr(attrs : Array[Attr], name : String) -> String? {
match ast_optional_attr(attrs, name) {
Some(value) if value != "" => Some(value)
_ => None
}
}
///|
fn ast_bool_attr(attrs : Array[Attr], name : String) -> Bool {
ast_optional_attr(attrs, name) == Some("true")
}
///|
fn ast_checked_attr(attrs : Array[Attr]) -> Bool? {
match ast_optional_attr(attrs, "gfm:checked") {
Some("true") => Some(true)
Some("false") => Some(false)
_ => None
}
}
///|
fn ast_int_attr(attrs : Array[Attr], name : String) -> Int? {
match ast_optional_attr(attrs, name) {
Some(value) => parse_ast_int(value)
None => None
}
}
///|
fn parse_ast_int(value : String) -> Int? {
guard value.length() > 0 else { None }
let mut out = 0
for i in 0..= 0 && digit <= 9 else { return None }
out = out * 10 + digit
}
Some(out)
}
///|
fn ast_text_value(nodes : Array[AstNode]) -> String {
let out = StringBuilder::new()
for node in nodes {
match node {
AstText(text) => out <+ "\{text}"
_ => ()
}
}
out.to_string()
}
///|
fn ast_list_item_children(nodes : Array[AstNode]) -> Array[AstNode] {
let out : Array[AstNode] = []
let pending : Array[AstNode] = []
for node in nodes {
if ast_is_phrasing(node) {
pending.push(copy_ast_node(node))
} else {
if pending.length() > 0 {
out.push(AstParagraph(copy_ast_nodes(pending)))
pending.clear()
}
out.push(copy_ast_node(node))
}
}
if pending.length() > 0 {
out.push(AstParagraph(copy_ast_nodes(pending)))
}
out
}
///|
fn ast_is_phrasing(node : AstNode) -> Bool {
match node {
AstText(_)
| AstEmphasis(_)
| AstStrong(_)
| AstInlineCode(_)
| AstBreak
| AstLink(_, _, _)
| AstImage(_, _, _)
| AstDelete(_)
| AstHtml(_) => true
_ => false
}
}
///|
fn ast_list_item_spread(nodes : Array[AstNode]) -> Bool {
for node in nodes {
if node is AstParagraph(_) {
return true
}
}
false
}
///|
fn ast_list_spread(nodes : Array[AstNode]) -> Bool {
for node in nodes {
match node {
AstListItem(spread, _, _) if spread => return true
_ => ()
}
}
false
}
///|
fn ast_table_align(nodes : Array[AstNode]) -> Array[String?] {
for node in nodes {
match node {
AstTableSection(rows) | AstTableRow(rows) =>
if ast_align_from_rows(rows) is Some(aligns) {
return aligns
}
_ => ()
}
}
[]
}
///|
fn ast_align_from_rows(rows : Array[AstNode]) -> Array[String?]? {
for row in rows {
match row {
AstTableRow(cells) => {
let aligns : Array[String?] = []
for cell in cells {
match cell {
AstTableCell(align, _) => aligns.push(align)
_ => ()
}
}
return Some(aligns)
}
_ => ()
}
}
None
}
///|
fn ast_table_rows(nodes : Array[AstNode]) -> Array[AstNode] {
let rows : Array[AstNode] = []
for node in nodes {
match node {
AstTableSection(section_rows) =>
for row in section_rows {
rows.push(copy_ast_node(row))
}
AstTableRow(_) => rows.push(copy_ast_node(node))
_ => ()
}
}
rows
}
///|
fn copy_attrs_for_ast(attrs : Array[Attr]) -> Array[Attr] {
let copied : Array[Attr] = []
for attr in attrs {
copied.push(attr)
}
copied
}
///|
fn copy_ast_nodes(nodes : Array[AstNode]) -> Array[AstNode] {
let copied : Array[AstNode] = []
for node in nodes {
copied.push(copy_ast_node(node))
}
copied
}
///|
fn copy_ast_node(node : AstNode) -> AstNode {
match node {
AstRoot(children) => AstRoot(copy_ast_nodes(children))
AstParagraph(children) => AstParagraph(copy_ast_nodes(children))
AstHeading(depth, children) => AstHeading(depth, copy_ast_nodes(children))
AstThematicBreak => AstThematicBreak
AstBlockquote(children) => AstBlockquote(copy_ast_nodes(children))
AstList(ordered, start, spread, children) =>
AstList(ordered, start, spread, copy_ast_nodes(children))
AstListItem(spread, checked, children) =>
AstListItem(spread, checked, copy_ast_nodes(children))
AstCode(value, lang, meta) => AstCode(value, lang, meta)
AstHtml(value) => AstHtml(value)
AstText(value) => AstText(value)
AstEmphasis(children) => AstEmphasis(copy_ast_nodes(children))
AstStrong(children) => AstStrong(copy_ast_nodes(children))
AstInlineCode(value) => AstInlineCode(value)
AstBreak => AstBreak
AstLink(url, title, children) =>
AstLink(url, title, copy_ast_nodes(children))
AstImage(url, title, alt) => AstImage(url, title, alt)
AstDelete(children) => AstDelete(copy_ast_nodes(children))
AstTable(align, children) =>
AstTable(copy_string_options(align), copy_ast_nodes(children))
AstTableRow(children) => AstTableRow(copy_ast_nodes(children))
AstTableCell(align, children) =>
AstTableCell(align, copy_ast_nodes(children))
AstTableSection(children) => AstTableSection(copy_ast_nodes(children))
AstExtension(tag, attrs, children) =>
AstExtension(tag, copy_attrs_for_ast(attrs), copy_ast_nodes(children))
}
}
///|
fn copy_string_options(values : Array[String?]) -> Array[String?] {
let copied : Array[String?] = []
for value in values {
copied.push(value)
}
copied
}
///|
pub impl ToJson for AstNode with fn to_json(self) {
match self {
AstRoot(children) =>
Json::object({
"type": Json::string("root"),
"children": ast_children_json(children),
})
AstParagraph(children) =>
Json::object({
"type": Json::string("paragraph"),
"children": ast_children_json(children),
})
AstHeading(depth, children) =>
Json::object({
"type": Json::string("heading"),
"depth": Json::number(depth.to_double()),
"children": ast_children_json(children),
})
AstThematicBreak => Json::object({ "type": Json::string("thematicBreak") })
AstBlockquote(children) =>
Json::object({
"type": Json::string("blockquote"),
"children": ast_children_json(children),
})
AstList(ordered, start, spread, children) =>
Json::object({
"type": Json::string("list"),
"ordered": Json::boolean(ordered),
"start": ast_int_option_json(start),
"spread": Json::boolean(spread),
"children": ast_children_json(children),
})
AstListItem(spread, checked, children) =>
Json::object({
"type": Json::string("listItem"),
"spread": Json::boolean(spread),
"checked": ast_bool_option_json(checked),
"children": ast_children_json(children),
})
AstCode(value, lang, meta) =>
Json::object({
"type": Json::string("code"),
"lang": ast_string_option_json(lang),
"meta": ast_string_option_json(meta),
"value": Json::string(value),
})
AstHtml(value) =>
Json::object({
"type": Json::string("html"),
"value": Json::string(value),
})
AstText(value) =>
Json::object({
"type": Json::string("text"),
"value": Json::string(value),
})
AstEmphasis(children) =>
Json::object({
"type": Json::string("emphasis"),
"children": ast_children_json(children),
})
AstStrong(children) =>
Json::object({
"type": Json::string("strong"),
"children": ast_children_json(children),
})
AstInlineCode(value) =>
Json::object({
"type": Json::string("inlineCode"),
"value": Json::string(value),
})
AstBreak => Json::object({ "type": Json::string("break") })
AstLink(url, title, children) =>
Json::object({
"type": Json::string("link"),
"url": Json::string(url),
"title": ast_string_option_json(title),
"children": ast_children_json(children),
})
AstImage(url, title, alt) =>
Json::object({
"type": Json::string("image"),
"url": Json::string(url),
"title": ast_string_option_json(title),
"alt": Json::string(alt),
})
AstDelete(children) =>
Json::object({
"type": Json::string("delete"),
"children": ast_children_json(children),
})
AstTable(align, children) =>
Json::object({
"type": Json::string("table"),
"align": ast_align_json(align),
"children": ast_children_json(children),
})
AstTableRow(children) =>
Json::object({
"type": Json::string("tableRow"),
"children": ast_children_json(children),
})
AstTableCell(_, children) =>
Json::object({
"type": Json::string("tableCell"),
"children": ast_children_json(children),
})
AstTableSection(children) =>
Json::object({
"type": Json::string("mdastExtension"),
"name": Json::string("gfm:table_section"),
"children": ast_children_json(children),
})
AstExtension(tag, attrs, children) =>
Json::object({
"type": Json::string("mdastExtension"),
"name": Json::string(tag),
"attributes": ast_attrs_json(attrs),
"children": ast_children_json(children),
})
}
}
///|
fn ast_children_json(children : Array[AstNode]) -> Json {
let out : Array[Json] = []
for child in children {
out.push(child.to_json())
}
Json::array(out)
}
///|
fn ast_align_json(align : Array[String?]) -> Json {
let out : Array[Json] = []
for value in align {
out.push(ast_string_option_json(value))
}
Json::array(out)
}
///|
fn ast_attrs_json(attrs : Array[Attr]) -> Json {
let out : Map[String, Json] = {}
for attr in attrs {
out[attr.name().to_string()] = Json::string(attr.value())
}
Json::object(out)
}
///|
fn ast_string_option_json(value : String?) -> Json {
match value {
Some(value) => Json::string(value)
None => Json::null()
}
}
///|
fn ast_int_option_json(value : Int?) -> Json {
match value {
Some(value) => Json::number(value.to_double())
None => Json::null()
}
}
///|
fn ast_bool_option_json(value : Bool?) -> Json {
match value {
Some(value) => Json::boolean(value)
None => Json::null()
}
}