///| CST Serializer - Convert CST back to markdown text (lossless roundtrip)
///|
/// 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::new()
// 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)
}
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::ThematicBreak(..) =>
// GFM style: no leading trivia, always use ***
buf.write_string("***\n")
Block::Heading(level~, style~, children~, ..) =>
// GFM style: no leading trivia, no closing hashes
match style {
HeadingStyle::Atx => {
write_chars(buf, '#', level)
if !children.is_empty() {
buf.write_char(' ')
serialize_inlines(children, buf)
}
buf.write_char('\n')
}
HeadingStyle::Setext => {
serialize_inlines(children, buf)
buf.write_char('\n')
let underline_char = if level == 1 { '=' } else { '-' }
write_chars(buf, underline_char, 3)
buf.write_char('\n')
}
}
Block::Paragraph(children~, ..) => {
// GFM style: no leading/trailing trivia
serialize_inlines(children, 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::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::new()
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::BulletList(items~, ..) =>
// Stable editor/source style: always use -, no marker_offset.
serialize_bullet_list_items(items, buf, 0)
Block::OrderedList(start~, items~, ..) =>
// GFM style: always use ., no marker_offset
serialize_ordered_list_items(items, buf, 0, start)
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)
// Header row
buf.write_char('|')
for cell in header {
buf.write_char(' ')
serialize_table_cell_inlines(cell.children, buf)
buf.write_string(" |")
}
buf.write_char('\n')
// Separator row (remark-gfm style)
buf.write_char('|')
for align in alignments {
match align {
TableAlign::Left => buf.write_string(" :-- |")
TableAlign::Center => buf.write_string(" :-: |")
TableAlign::Right => buf.write_string(" --: |")
TableAlign::None => buf.write_string(" --- |")
}
}
buf.write_char('\n')
// Data rows
for row in rows {
buf.write_char('|')
for cell in row {
buf.write_char(' ')
serialize_table_cell_inlines(cell.children, buf)
buf.write_string(" |")
}
buf.write_char('\n')
}
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::new()
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)
}
}
}
///|
/// Serialize link definitions
pub fn serialize_definitions(defs : Array[LinkDefinition]) -> String {
let buf = StringBuilder::new()
for def in defs {
buf.write_char('[')
buf.write_string(def.label)
buf.write_string("]: ")
buf.write_string(def.url)
if !def.title.is_empty() {
buf.write_string(" \"")
buf.write_string(def.title)
buf.write_char('"')
}
buf.write_char('\n')
}
buf.to_string()
}
///|
/// Serialize bullet list items with indentation
fn serialize_bullet_list_items(
items : Array[ListItem],
buf : StringBuilder,
indent : Int,
) -> Unit {
for item in items {
// Write indentation
write_chars(buf, ' ', indent)
buf.write_string("- ")
// Task list checkbox
match item.checked {
Some(true) => buf.write_string("[x] ")
Some(false) => buf.write_string("[ ] ")
None => ()
}
// Serialize item content
let mut first_block = true
for child in item.children {
match child {
Block::Paragraph(children=para_children, ..) => {
serialize_inlines(para_children, buf)
buf.write_char('\n')
first_block = false
}
Block::BulletList(items=nested_items, ..) => {
// Nested list - serialize with increased indentation
if first_block {
buf.write_char('\n')
}
serialize_bullet_list_items(nested_items, buf, indent + 2)
}
_ => {
serialize_block(child, buf)
first_block = false
}
}
}
// If no content was written, add newline
if first_block {
buf.write_char('\n')
}
}
}
///|
/// Serialize ordered list items with indentation
fn serialize_ordered_list_items(
items : Array[ListItem],
buf : StringBuilder,
indent : Int,
start : Int,
) -> Unit {
let mut num = start
for item in items {
// Write indentation
write_chars(buf, ' ', indent)
buf.write_string(num.to_string())
buf.write_string(". ")
// Task list checkbox (rare for ordered lists but supported)
match item.checked {
Some(true) => buf.write_string("[x] ")
Some(false) => buf.write_string("[ ] ")
None => ()
}
// Serialize item content
let mut first_block = true
for child in item.children {
match child {
Block::Paragraph(children=para_children, ..) => {
serialize_inlines(para_children, buf)
buf.write_char('\n')
first_block = false
}
Block::BulletList(items=nested_items, ..) => {
// Nested bullet list - serialize with increased indentation
if first_block {
buf.write_char('\n')
}
serialize_bullet_list_items(nested_items, buf, indent + 3)
}
Block::OrderedList(items=nested_items, start=nested_start, ..) => {
// Nested ordered list
if first_block {
buf.write_char('\n')
}
serialize_ordered_list_items(
nested_items,
buf,
indent + 3,
nested_start,
)
}
_ => {
serialize_block(child, buf)
first_block = false
}
}
}
// If no content was written, add newline
if first_block {
buf.write_char('\n')
}
num += 1
}
}