///| CST Serializer - Convert CST to normalized Markdown.
///|
/// Parse markdown source and serialize back to a normalized string.
/// Convenience wrapper around `parse` + `serialize` used by compatibility tests.
pub fn md_parse_and_render(
source : String,
strict? : Bool = false,
wikilinks? : Bool = false,
) -> String {
let result = parse(source, strict~, wikilinks~)
serialize(result.document)
}
///|
/// Serialize document to markdown string
pub fn serialize(doc : Document) -> String {
let buf = StringBuilder()
// Serialize frontmatter if present
match doc.frontmatter {
Some(fm) => serialize_frontmatter(fm, buf)
None => ()
}
// Serialize blocks with blank lines between them (GFM style)
let mut first = true
for block in doc.children {
// Skip leading BlankLines
match block {
Block::BlankLines(..) => continue
_ => ()
}
// Add blank line between blocks (except for first block)
if !first {
buf.write_char('\n')
}
first = false
serialize_block(block, buf)
}
// Definitions are semantic document data even though they are removed from
// paragraph blocks during parsing. Emit them in one canonical section.
if !doc.definitions.is_empty() {
if !first {
buf.write_char('\n')
}
serialize_definitions_into(doc.definitions, buf)
}
buf.to_string()
}
///|
/// Serialize frontmatter
fn serialize_frontmatter(fm : Frontmatter, buf : StringBuilder) -> Unit {
buf.write_string("---\n")
buf.write_string(fm.raw)
if !fm.raw.has_suffix("\n") {
buf.write_char('\n')
}
buf.write_string("---\n")
}
///|
/// Write n copies of a character
fn write_chars(buf : StringBuilder, c : Char, n : Int) -> Unit {
for i = 0; i < n; i = i + 1 {
buf.write_char(c)
}
}
///| Calculate minimum backticks for code span
///|
/// Returns the smallest count that doesn't match any backtick run in content
fn calc_code_span_backticks(content : String) -> Int {
// Track which run lengths exist using a simple bitmask for small values
// For runs > 63, we fall back to max_run + 1
let mut run_mask : UInt64 = 0 // bit N set means run of length N exists
let mut max_run = 0
let mut current_run = 0
for c in content {
if c == '`' {
current_run += 1
} else if current_run > 0 {
if current_run <= 63 {
run_mask = run_mask | (1UL << current_run)
}
if current_run > max_run {
max_run = current_run
}
current_run = 0
}
}
if current_run > 0 {
if current_run <= 63 {
run_mask = run_mask | (1UL << current_run)
}
if current_run > max_run {
max_run = current_run
}
}
// Find minimum N that is not in runs
let mut n = 1
while n <= 63 && (run_mask & (1UL << n)) != 0 {
n += 1
}
// If we exceeded 63, use max_run + 1
if n > 63 {
max_run + 1
} else {
n
}
}
///|
/// Calculate minimum fence length for code block
fn calc_fence_length(code : String) -> Int {
let mut fence_len = 3
let mut count = 0
for c in code {
if c == '`' {
count = count + 1
} else {
if count >= fence_len {
fence_len = count + 1
}
count = 0
}
}
// Check final run of backticks
if count >= fence_len {
fence_len = count + 1
}
fence_len
}
///|
/// Serialize a block
fn serialize_block(block : Block, buf : StringBuilder) -> Unit {
match block {
Block::Attributed(block~, attributes~, ..) => {
serialize_block(block, buf)
serialize_attribute_list(attributes, buf)
buf.write_char('\n')
}
Block::ThematicBreak(..) =>
// GFM style: no leading trivia, always use ***
buf.write_string("***\n")
Block::Heading(level~, children~, ..) => {
// Canonical headings always use ATX syntax. This avoids an underline
// whose meaning can change when adjacent block content is normalized.
write_chars(buf, '#', level)
if !children.is_empty() {
buf.write_char(' ')
serialize_inlines(children, buf)
}
buf.write_char('\n')
}
Block::Paragraph(children~, ..) => {
// GFM style: no leading/trailing trivia
let content = StringBuilder()
serialize_inlines(children, content)
write_paragraph_content(content.to_string(), buf)
buf.write_char('\n')
}
Block::FencedCode(info~, code~, ..) => {
// GFM style: always use ``` (remark default)
let fence_len = calc_fence_length(code)
write_chars(buf, '`', fence_len)
if !info.is_empty() {
buf.write_string(info)
}
buf.write_char('\n')
buf.write_string(code)
if !code.is_empty() && !code.has_suffix("\n") {
buf.write_char('\n')
}
write_chars(buf, '`', fence_len)
buf.write_char('\n')
}
Block::MathBlock(value~, ..) => {
let fence_len = math_fence_length(value)
write_chars(buf, '$', fence_len)
buf.write_char('\n')
buf.write_string(value)
if !value.has_suffix("\n") {
buf.write_char('\n')
}
write_chars(buf, '$', fence_len)
buf.write_char('\n')
}
Block::Directive(name~, meta~, children~, ..) => {
buf.write_string(":::")
buf.write_string(name)
if !meta.is_empty() {
buf.write_char(' ')
buf.write_string(meta)
}
buf.write_char('\n')
let body = StringBuilder()
for child in children {
serialize_block(child, body)
}
let rendered = body.to_string()
buf.write_string(rendered)
if !rendered.is_empty() && !rendered.has_suffix("\n") {
buf.write_char('\n')
}
buf.write_string(":::\n")
}
Block::DefinitionList(items~, ..) =>
for item in items {
serialize_inlines(item.term, buf)
buf.write_char('\n')
for definition in item.definitions {
buf.write_string(": ")
serialize_inlines(definition, buf)
buf.write_char('\n')
}
}
Block::IndentedCode(code~, ..) => {
// remark converts indented code to fenced code
let fence_len = calc_fence_length(code)
write_chars(buf, '`', fence_len)
buf.write_char('\n')
buf.write_string(code)
if !code.is_empty() && !code.has_suffix("\n") {
buf.write_char('\n')
}
write_chars(buf, '`', fence_len)
buf.write_char('\n')
}
Block::Blockquote(children~, ..) =>
// Serialize each block with > prefix
for child in children {
let child_buf = StringBuilder()
serialize_block(child, child_buf)
let child_str = child_buf.to_string()
for line in child_str.split("\n") {
let line_str = line.to_owned()
if !line_str.is_empty() {
buf.write_string("> ")
buf.write_string(line_str)
buf.write_char('\n')
}
}
}
Block::Alert(kind~, children~, ..) => {
buf.write_string("> [!")
buf.write_string(alert_kind_slug(kind).to_upper())
buf.write_string("]\n")
for child in children {
let child_buf = StringBuilder()
serialize_block(child, child_buf)
for line in child_buf.to_string().split("\n") {
let line = line.to_owned()
if !line.is_empty() {
buf.write_string("> ")
buf.write_string(line)
buf.write_char('\n')
}
}
}
}
Block::BulletList(items~, tight~, ..) =>
// Stable editor/source style: always use -, no marker_offset.
serialize_bullet_list_items(items, tight, buf)
Block::OrderedList(start~, items~, tight~, ..) =>
// GFM style: always use ., no marker_offset
serialize_ordered_list_items(items, start, tight, buf)
Block::HtmlBlock(html~, ..) => {
buf.write_string(html)
if !html.has_suffix("\n") {
buf.write_char('\n')
}
}
Block::Table(
header~,
alignments~,
rows~,
leading_trivia~,
trailing_trivia~,
..
) => {
buf.write_string(leading_trivia.content)
serialize_pretty_table(header, alignments, rows, buf)
buf.write_string(trailing_trivia.content)
}
Block::BlankLines(count~, ..) =>
for i = 0; i < count; i = i + 1 {
buf.write_char('\n')
}
Block::FootnoteDefinition(
label~,
children~,
leading_trivia~,
trailing_trivia~,
..
) => {
buf.write_string(leading_trivia.content)
buf.write_string("[^")
buf.write_string(label)
buf.write_string("]: ")
// Serialize children (usually a paragraph)
let mut first = true
for child in children {
if !first {
buf.write_string("\n ") // Indent continuation
}
let child_buf = StringBuilder()
serialize_block(child, child_buf)
let child_str = child_buf.to_string().trim_end(chars="\n").to_owned()
buf.write_string(child_str)
first = false
}
buf.write_char('\n')
buf.write_string(trailing_trivia.content)
}
}
}
///|
/// Write paragraph content while protecting a leading pipe on each line.
/// This prevents a non-table paragraph from becoming a table after formatting.
fn write_paragraph_content(content : String, buf : StringBuilder) -> Unit {
let mut line_start = true
for c in content {
if line_start && c == '|' {
buf.write_char('\\')
}
buf.write_char(c)
line_start = c == '\n'
}
}
///|
/// Serialize a GFM table using stable remark-compatible column widths.
fn serialize_pretty_table(
header : Array[TableCell],
alignments : Array[TableAlign],
rows : Array[Array[TableCell]],
buf : StringBuilder,
) -> Unit {
let rendered_header = render_table_row_cells(header)
let rendered_rows : Array[Array[String]] = []
let columns = alignments.length()
for row in rows {
let rendered = render_table_row_cells(row)
rendered_rows.push(rendered)
}
let widths = calculate_table_widths(rendered_header, rendered_rows, columns)
write_pretty_table_row(rendered_header, alignments, widths, buf)
buf.write_char('|')
for i = 0; i < columns; i = i + 1 {
buf.write_char(' ')
let align = if i < alignments.length() {
alignments[i]
} else {
TableAlign::None
}
write_table_delimiter(align, widths[i], buf)
buf.write_string(" |")
}
buf.write_char('\n')
for row in rendered_rows {
write_pretty_table_row(row, alignments, widths, buf)
}
}
///|
/// Calculate the rendered width of every normalized table column.
fn calculate_table_widths(
header : Array[String],
rows : Array[Array[String]],
columns : Int,
) -> Array[Int] {
let widths = Array::make(columns, 3)
update_table_widths(header, widths)
for row in rows {
update_table_widths(row, widths)
}
widths
}
///|
/// Render cells before measuring them so escapes and Markdown markers count.
fn render_table_row_cells(cells : Array[TableCell]) -> Array[String] {
let rendered : Array[String] = []
for cell in cells {
let cell_buf = StringBuilder()
serialize_table_cell_inlines(cell.children, cell_buf)
rendered.push(cell_buf.to_string())
}
rendered
}
///|
/// Expand column widths to fit one rendered row.
fn update_table_widths(row : Array[String], widths : Array[Int]) -> Unit {
for i, cell in row {
if i < widths.length() && cell.length() > widths[i] {
widths[i] = cell.length()
}
}
}
///|
/// Write one padded table row.
fn write_pretty_table_row(
row : Array[String],
alignments : Array[TableAlign],
widths : Array[Int],
buf : StringBuilder,
) -> Unit {
buf.write_char('|')
for i, width in widths {
let cell = if i < row.length() { row[i] } else { "" }
let gap = width - cell.length()
let align = if i < alignments.length() {
alignments[i]
} else {
TableAlign::None
}
let left = match align {
TableAlign::Right => gap
TableAlign::Center => gap / 2
_ => 0
}
let right = gap - left
buf.write_char(' ')
write_chars(buf, ' ', left)
buf.write_string(cell)
write_chars(buf, ' ', right)
buf.write_string(" |")
}
buf.write_char('\n')
}
///|
/// Write the alignment marker at exactly the measured column width.
fn write_table_delimiter(
align : TableAlign,
width : Int,
buf : StringBuilder,
) -> Unit {
match align {
TableAlign::Left => {
buf.write_char(':')
write_chars(buf, '-', width - 1)
}
TableAlign::Center => {
buf.write_char(':')
write_chars(buf, '-', width - 2)
buf.write_char(':')
}
TableAlign::Right => {
write_chars(buf, '-', width - 1)
buf.write_char(':')
}
TableAlign::None => write_chars(buf, '-', width)
}
}
///|
/// Serialize normalized block attributes using shorthand where possible.
fn serialize_attribute_list(
attributes : Array[MarkdownAttribute],
buf : StringBuilder,
) -> Unit {
buf.write_char('{')
for i, attribute in attributes {
if i > 0 {
buf.write_char(' ')
}
if attribute.name == "id" {
buf.write_char('#')
buf.write_string(attribute.value)
} else if attribute.name == "class" {
buf.write_char('.')
buf.write_string(attribute.value)
} else {
buf.write_string(attribute.name)
if !attribute.value.is_empty() {
buf.write_char('=')
let needs_quotes = attribute.value.contains(" ") ||
attribute.value.contains("\t")
if needs_quotes {
buf.write_char('"')
}
for c in attribute.value {
if needs_quotes && (c == '"' || c == '\\') {
buf.write_char('\\')
}
buf.write_char(c)
}
if needs_quotes {
buf.write_char('"')
}
}
}
}
buf.write_char('}')
}
///|
/// Choose a closing fence longer than any dollar-only line in the value.
fn math_fence_length(value : String) -> Int {
let mut longest = 1
for line in value.split("\n") {
let line = line.trim(chars=" \t\r")
let mut all_dollars = !line.is_empty()
for c in line {
if c != '$' {
all_dollars = false
break
}
}
if all_dollars && line.length() > longest {
longest = line.length()
}
}
if longest < 2 {
2
} else {
longest + 1
}
}
///|
/// Serialize link definitions
pub fn serialize_definitions(defs : Array[LinkDefinition]) -> String {
let buf = StringBuilder()
serialize_definitions_into(defs, buf)
buf.to_string()
}
///| Write the canonical definition section. Definitions are separated by a
///|
/// blank line to match the normalized block spacing used elsewhere.
fn serialize_definitions_into(
defs : Array[LinkDefinition],
buf : StringBuilder,
) -> Unit {
for i, def in defs {
if i > 0 {
buf.write_char('\n')
}
buf.write_char('[')
buf.write_string(def.label)
buf.write_string("]: ")
write_link_destination(def.url, buf)
if !def.title.is_empty() {
write_link_title(def.title, buf)
}
buf.write_char('\n')
}
}
///|
/// Write `text` so that every line after the first is indented, leaving room
/// for the list marker on the first one.
fn write_with_continuation_indent(
text : String,
indent : Int,
buf : StringBuilder,
) -> Unit {
let len = text.length()
let mut at_line_start = false
for i = 0; i < len; i = i + 1 {
let c = text.unsafe_get(i)
if at_line_start && c != '\n' {
write_chars(buf, ' ', indent)
}
buf.write_string(text.unsafe_substring(start=i, end=i + 1))
at_line_start = c == '\n'
}
}
///|
/// Serialize one list item's content, without the marker. Loose lists put a
/// blank line between the blocks of an item.
fn serialize_list_item_content(
item : ListItem,
tight : Bool,
buf : StringBuilder,
) -> Unit {
match item.checked {
Some(true) => buf.write_string("[x] ")
Some(false) => buf.write_string("[ ] ")
None => ()
}
let mut first = true
for child in item.children {
if !first && !tight {
buf.write_char('\n')
}
serialize_block(child, buf)
first = false
}
}
///|
/// Write a list item: its marker, then its content with every following line
/// indented to line up under it.
fn serialize_list_item(
item : ListItem,
marker : String,
tight : Bool,
buf : StringBuilder,
) -> Unit {
let inner = StringBuilder()
serialize_list_item_content(item, tight, inner)
let text = inner.to_string()
buf.write_string(marker)
if text.is_empty() {
buf.write_char('\n')
return
}
buf.write_char(' ')
write_with_continuation_indent(text, marker.length() + 1, buf)
}
///|
/// Serialize bullet list items
fn serialize_bullet_list_items(
items : Array[ListItem],
tight : Bool,
buf : StringBuilder,
) -> Unit {
for item in items {
serialize_list_item(item, "-", tight, buf)
}
}
///|
/// Serialize ordered list items
fn serialize_ordered_list_items(
items : Array[ListItem],
start : Int,
tight : Bool,
buf : StringBuilder,
) -> Unit {
let mut num = start
for item in items {
serialize_list_item(item, num.to_string() + ".", tight, buf)
num = num + 1
}
}