//! Terminal renderer for the `@cmark` event stream.
//!
//! Mirrors `terminal.mbt`'s styling decisions but walks the flat
//! `Array[@cmark.Event]` produced by the pulldown-cmark port, reusing the
//! shared math and ANSI infrastructure of this package.
///|
priv struct CommonMarkOptions {
line_style : LineStyle
ansi : Bool
disable_math : Bool
halfwidth_punct : Bool
}
///|
priv struct ListContext {
start : Int64?
index : Int
}
///|
/// Renders a cmark event stream as terminal text.
pub fn render_terminal_events(
events : Array[@cmark.Event],
line_style? : LineStyle = Unicode,
ansi? : Bool = true,
disable_math? : Bool = false,
halfwidth_punct? : Bool = false,
) -> String {
let opts = CommonMarkOptions::{
line_style,
ansi,
disable_math,
halfwidth_punct,
}
let out = Array::new()
let mut i = 0
while i < events.length() {
match events[i] {
Start(tag) => {
let (s, next) = cm_render_block(
events,
i,
tag,
{ start: None, index: 0 },
opts,
)
if !s.is_empty() {
out.push(s)
}
i = next
}
Rule => {
out.push(
term_style(
"─".repeat(32),
ansi=opts.ansi,
modifier=@chalk.Modifier::Dim,
),
)
i += 1
}
DisplayMath(latex) => {
out.push(cm_render_math_display(latex, opts))
i += 1
}
_ => i += 1
}
}
out.join("\n\n")
}
///|
fn cm_render_block(
events : Array[@cmark.Event],
start_ix : Int,
tag : @cmark.Tag,
list_ctx : ListContext,
opts : CommonMarkOptions,
) -> (String, Int) {
match tag {
Table(alignments) => cm_render_table(events, start_ix, alignments, opts)
List(start) => cm_render_list(events, start_ix, start, opts)
_ => cm_render_generic_block(events, start_ix, tag, list_ctx, opts)
}
}
///|
fn cm_render_generic_block(
events : Array[@cmark.Event],
start_ix : Int,
tag : @cmark.Tag,
list_ctx : ListContext,
opts : CommonMarkOptions,
) -> (String, Int) {
let (content, blocks, task, next) = cm_collect(
events,
start_ix,
opts,
cm_is_raw_text_block(tag),
)
(cm_assemble_block(tag, content, blocks, list_ctx, task, opts), next)
}
///|
fn cm_is_raw_text_block(tag : @cmark.Tag) -> Bool {
match tag {
CodeBlock(_) | HtmlBlock | MetadataBlock(_) => true
_ => false
}
}
///|
fn cm_collect(
events : Array[@cmark.Event],
start_ix : Int,
opts : CommonMarkOptions,
raw_text : Bool,
) -> (Array[String], Array[String], Bool?, Int) {
let content = Array::new()
let blocks = Array::new()
let mut task : Bool? = None
let mut last_char : Char? = None
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
End(_) => break
Start(child) => {
let is_inline = cm_is_inline_tag(child)
let (s, next) = if is_inline {
cm_render_inline(events, i, child, opts, prev=last_char)
} else {
cm_render_block(events, i, child, { start: None, index: 0 }, opts)
}
if is_inline {
content.push(s)
last_char = cm_event_last_char(events, i)
} else {
blocks.push(s)
last_char = None
}
i = next
}
TaskListMarker(checked) => {
task = Some(checked)
last_char = None
i += 1
}
Text(s) => {
let next_c = if i + 1 < events.length() {
cm_event_first_char(events, i + 1)
} else {
None
}
content.push(
if raw_text {
s
} else {
cm_halfwidth_text(s, opts, prev=last_char, next=next_c)
},
)
last_char = if raw_text { None } else { s.iter().last() }
i += 1
}
Code(s) => {
content.push(
term_style(
s,
ansi=opts.ansi,
background=@chalk.BackgroundColor::BgWhiteBright,
),
)
last_char = None
i += 1
}
InlineMath(latex) => {
content.push(cm_render_math_inline(latex, opts))
last_char = None
i += 1
}
DisplayMath(latex) => {
blocks.push(cm_render_math_display(latex, opts))
last_char = None
i += 1
}
Html(s) => {
content.push(s)
last_char = None
i += 1
}
InlineHtml(s) => {
content.push(s)
last_char = None
i += 1
}
FootnoteReference(s) => {
content.push(
term_style("[" + s + "]", ansi=opts.ansi, color=@chalk.Color::Yellow),
)
last_char = None
i += 1
}
SoftBreak => {
content.push("\n")
last_char = None
i += 1
}
HardBreak => {
content.push("\n")
last_char = None
i += 1
}
Rule => {
blocks.push(
term_style(
"─".repeat(32),
ansi=opts.ansi,
modifier=@chalk.Modifier::Dim,
),
)
last_char = None
i += 1
}
}
}
(content, blocks, task, i + 1)
}
///|
fn cm_assemble_block(
tag : @cmark.Tag,
content : Array[String],
blocks : Array[String],
list_ctx : ListContext,
task : Bool?,
opts : CommonMarkOptions,
) -> String {
match tag {
Paragraph => cm_join_content_blocks(content, blocks)
Heading(level~, ..) =>
term_style(
content.join(""),
ansi=opts.ansi,
color=cm_heading_color(level),
modifier=@chalk.Modifier::Bold,
modifier2=@chalk.Modifier::Underline,
)
BlockQuote(_) => cm_render_blockquote(content, blocks, opts)
CodeBlock(_) =>
term_style(
content.join("").trim_end().to_owned(),
ansi=opts.ansi,
background=@chalk.BackgroundColor::BgWhiteBright,
modifier=@chalk.Modifier::Dim,
)
HtmlBlock => content.join("")
Item => cm_assemble_list_item(content, blocks, list_ctx, task, opts)
FootnoteDefinition(label) =>
"[" + label + "]: " + cm_join_content_blocks(content, blocks)
DefinitionList => cm_join_content_blocks(content, blocks)
DefinitionListTitle =>
term_style(
content.join(""),
ansi=opts.ansi,
modifier=@chalk.Modifier::Bold,
)
DefinitionListDefinition(_) => cm_join_content_blocks(content, blocks)
MetadataBlock(_) => content.join("")
ContainerBlock(_, desc) => {
let body = cm_join_content_blocks(content, blocks)
guard !desc.is_empty() else { body }
term_style(desc, ansi=opts.ansi, modifier=@chalk.Modifier::Dim) +
"\n\n" +
body
}
_ => content.join("")
}
}
///|
fn cm_heading_color(level : @cmark.HeadingLevel) -> @chalk.Color {
match level {
H1 => @chalk.Color::Red
H2 => @chalk.Color::Yellow
H3 => @chalk.Color::Green
_ => @chalk.Color::White
}
}
///|
fn cm_render_blockquote(
content : Array[String],
blocks : Array[String],
opts : CommonMarkOptions,
) -> String {
let base = cm_join_content_blocks(content, blocks)
let joined = base
.split("\n")
.map(fn(line) { "│ " + line.to_owned() })
.join("\n")
term_style(
joined,
ansi=opts.ansi,
color=@chalk.Color::WhiteBright,
modifier=@chalk.Modifier::Italic,
)
}
///|
fn cm_join_content_blocks(
content : Array[String],
blocks : Array[String],
) -> String {
let inline = content.join("")
[..if !inline.is_empty() { [inline] }, ..blocks].join("\n\n")
}
///|
fn cm_assemble_list_item(
content : Array[String],
blocks : Array[String],
list_ctx : ListContext,
task : Bool?,
opts : CommonMarkOptions,
) -> String {
let inline = content.join("")
let (first, children) = match blocks {
[b, .. rest] if inline.is_empty() => (b, rest)
_ => (inline, blocks[:])
}
let marker = cm_item_marker(list_ctx, task, opts.ansi)
let sb = StringBuilder()
sb.write_string(marker + " " + first)
if !children.is_empty() {
let indented = children
.join("\n\n")
.split("\n")
.map(fn(line) { " " + line.to_owned() })
.join("\n")
sb.write_string("\n")
sb.write_string(indented)
}
sb.to_string()
}
///|
fn cm_item_marker(list_ctx : ListContext, task : Bool?, ansi : Bool) -> String {
match task {
Some(true) =>
term_style(
"✔",
ansi~,
color=@chalk.Color::Green,
modifier=@chalk.Modifier::Bold,
)
Some(false) => term_style("○", ansi~, color=@chalk.Color::WhiteBright)
None =>
match list_ctx.start {
Some(n) => (n + list_ctx.index.to_int64()).to_string() + "."
None => "•"
}
}
}
///|
fn cm_render_list(
events : Array[@cmark.Event],
start_ix : Int,
start : Int64?,
opts : CommonMarkOptions,
) -> (String, Int) {
let items = Array::new()
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
Start(Item) => {
let (s, next) = cm_render_block(
events,
i,
@cmark.Tag::Item,
{ start, index: items.length() },
opts,
)
if !s.is_empty() {
items.push(s)
}
i = next
}
End(_) => break
_ => i += 1
}
}
(items.join("\n"), i + 1)
}
///|
fn cm_render_table(
events : Array[@cmark.Event],
start_ix : Int,
alignments : Array[@cmark.Alignment],
opts : CommonMarkOptions,
) -> (String, Int) {
let mut header : Array[String] = Array::new()
let rows : Array[Array[String]] = Array::new()
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
Start(TableHead) | Start(TableRow) => {
let (cells, next) = cm_render_table_row(events, i, opts)
if header.is_empty() {
header = cells
} else {
rows.push(cells)
}
i = next
}
End(_) => break
_ => i += 1
}
}
(cm_render_table_str(header, rows, alignments, opts), i + 1)
}
///|
fn cm_render_table_row(
events : Array[@cmark.Event],
start_ix : Int,
opts : CommonMarkOptions,
) -> (Array[String], Int) {
let cells = Array::new()
let mut cell : Array[String] = Array::new()
let mut last_char : Char? = None
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
Start(TableCell) => i += 1
End(TableCell) => {
cells.push(cell.join(""))
cell = Array::new()
last_char = None
i += 1
}
End(_) => break
Start(child) => {
let (s, next) = cm_render_inline(events, i, child, opts, prev=last_char)
cell.push(s)
last_char = cm_event_last_char(events, i)
i = next
}
Text(s) => {
let next_c = if i + 1 < events.length() {
cm_event_first_char(events, i + 1)
} else {
None
}
cell.push(cm_halfwidth_text(s, opts, prev=last_char, next=next_c))
last_char = s.iter().last()
i += 1
}
_ => {
cell.push(cm_render_inline_leaf(events[i], opts))
last_char = None
i += 1
}
}
}
(cells, i + 1)
}
///|
fn cm_render_table_str(
header : Array[String],
rows : Array[Array[String]],
alignments : Array[@cmark.Alignment],
opts : CommonMarkOptions,
) -> String {
let col_count = header.length()
let widths = Array::make(col_count, 0)
for i in 0.. widths[i] {
widths[i] = row[i].length()
}
}
}
let h_bar = line_style_h_bar(opts.line_style)
let cross = line_style_cross(opts.line_style)
let v_bar = line_style_v_bar(opts.line_style)
let header_row = cm_render_table_row_str(
header, widths, alignments, v_bar, opts, true,
)
let separator = widths.map(fn(w) { h_bar.repeat(w) }).join(cross)
let body_rows = rows
.map(fn(r) {
cm_render_table_row_str(r, widths, alignments, v_bar, opts, false)
})
.join("\n")
[header_row, separator, body_rows].join("\n")
}
///|
fn cm_render_table_row_str(
cells : Array[String],
widths : Array[Int],
alignments : Array[@cmark.Alignment],
v_bar : String,
opts : CommonMarkOptions,
is_header : Bool,
) -> String {
let parts = Array::new()
for i in 0.. String {
let pad = width - cell.length()
match alignment {
Center => {
let left = pad / 2
let right = pad - left
" ".repeat(left) + cell + " ".repeat(right)
}
Right => " ".repeat(pad) + cell
_ => cell + " ".repeat(pad)
}
}
///|
fn cm_is_inline_tag(tag : @cmark.Tag) -> Bool {
match tag {
Emphasis
| Strong
| Strikethrough
| Highlight
| Superscript
| Subscript
| Link(_)
| Image(_) => true
_ => false
}
}
///|
fn cm_render_inline(
events : Array[@cmark.Event],
start_ix : Int,
tag : @cmark.Tag,
opts : CommonMarkOptions,
prev~ : Char? = None,
) -> (String, Int) {
let content = Array::new()
let mut last_char : Char? = prev
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
End(_) => break
Start(child) => {
let (s, next) = cm_render_inline(events, i, child, opts, prev=last_char)
content.push(s)
last_char = cm_event_last_char(events, i)
i = next
}
Text(s) => {
let next_c = if i + 1 < events.length() {
cm_event_first_char(events, i + 1)
} else {
None
}
content.push(cm_halfwidth_text(s, opts, prev=last_char, next=next_c))
last_char = s.iter().last()
i += 1
}
_ => {
content.push(cm_render_inline_leaf(events[i], opts))
last_char = None
i += 1
}
}
}
(cm_apply_inline_style(tag, content.join(""), opts), i + 1)
}
///|
/// Index of the event right after the `End` matching the `Start` at `start_ix`.
fn cm_skip_subtree(events : Array[@cmark.Event], start_ix : Int) -> Int {
let mut depth = 0
let mut i = start_ix
while i < events.length() {
match events[i] {
Start(_) => depth += 1
End(_) => {
depth -= 1
if depth == 0 {
return i + 1
}
}
_ => ()
}
i += 1
}
i
}
///|
/// The first raw text character inside the subtree starting at `start_ix`.
fn cm_subtree_first_char(events : Array[@cmark.Event], start_ix : Int) -> Char? {
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
End(_) => return None
Text(s) => return s.iter().next()
Start(_) => {
match cm_subtree_first_char(events, i) {
Some(c) => return Some(c)
None => {
i = cm_skip_subtree(events, i)
continue
}
}
}
_ => i += 1
}
}
None
}
///|
/// The last raw text character inside the subtree starting at `start_ix`.
fn cm_subtree_last_char(events : Array[@cmark.Event], start_ix : Int) -> Char? {
let mut last : Char? = None
let mut i = start_ix + 1
while i < events.length() {
match events[i] {
End(_) => return last
Text(s) => {
last = s.iter().last()
i += 1
}
Start(_) => {
match cm_subtree_last_char(events, i) {
Some(c) => last = Some(c)
None => ()
}
i = cm_skip_subtree(events, i)
}
_ => i += 1
}
}
last
}
///|
/// The first raw text character of the inline content starting at event `ix`.
fn cm_event_first_char(events : Array[@cmark.Event], ix : Int) -> Char? {
if ix >= events.length() {
return None
}
match events[ix] {
Text(s) => s.iter().next()
Start(_) => cm_subtree_first_char(events, ix)
_ => None
}
}
///|
/// The last raw text character of the inline content starting at event `ix`.
fn cm_event_last_char(events : Array[@cmark.Event], ix : Int) -> Char? {
if ix >= events.length() {
return None
}
match events[ix] {
Text(s) => s.iter().last()
Start(_) => cm_subtree_last_char(events, ix)
_ => None
}
}
///|
fn cm_render_inline_leaf(ev : @cmark.Event, opts : CommonMarkOptions) -> String {
match ev {
Text(s) => cm_halfwidth_text(s, opts)
Code(s) =>
term_style(
s,
ansi=opts.ansi,
background=@chalk.BackgroundColor::BgWhiteBright,
)
InlineMath(latex) => cm_render_math_inline(latex, opts)
InlineHtml(s) => s
Html(s) => s
FootnoteReference(s) =>
term_style("[" + s + "]", ansi=opts.ansi, color=@chalk.Color::Yellow)
SoftBreak => "\n"
HardBreak => "\n"
_ => ""
}
}
///|
fn cm_halfwidth_text(
s : String,
opts : CommonMarkOptions,
prev~ : Char? = None,
next~ : Char? = None,
) -> String {
guard !opts.halfwidth_punct else { to_halfwidth_punct(s, prev~, next~) }
s
}
///|
fn cm_apply_inline_style(
tag : @cmark.Tag,
text : String,
opts : CommonMarkOptions,
) -> String {
match tag {
Emphasis =>
term_style(text, ansi=opts.ansi, modifier=@chalk.Modifier::Italic)
Strong => term_style(text, ansi=opts.ansi, modifier=@chalk.Modifier::Bold)
Strikethrough =>
term_style(text, ansi=opts.ansi, modifier=@chalk.Modifier::Strikethrough)
Highlight => "==" + text + "=="
Superscript => "^{" + text + "}"
Subscript => "_{" + text + "}"
Link(link_type~, dest_url~, ..) =>
match link_type {
Autolink | Email => text
_ => "[" + text + "](" + dest_url + ")"
}
Image(dest_url~, ..) =>
term_style(
"[img: " + text + "](" + dest_url + ")",
ansi=opts.ansi,
color=@chalk.Color::Yellow,
modifier=@chalk.Modifier::Italic,
)
_ => text
}
}
///|
fn cm_render_math_inline(latex : String, opts : CommonMarkOptions) -> String {
guard !opts.disable_math else { "$" + latex + "$" }
render_math(latex, false, target=Unicode)
}
///|
fn cm_render_math_display(latex : String, opts : CommonMarkOptions) -> String {
guard !opts.disable_math else { "$$" + latex + "$$" }
render_math(latex, true, target=Unicode)
}