use crate::island::KnownIslandType;
use crate::model::{Container, Island, LineKind, MarkKind, Content, ISLAND_SLOT};
pub fn to_markdown(rt: &Content) -> String {
let segments = line_segments(rt);
let ctx = Ctx {
rt,
segments: &segments,
};
let mut out = String::new();
emit_block(&ctx, 0..rt.lines.len(), 0, &mut out);
while out.ends_with('\n') {
out.pop();
}
out
}
pub fn to_plaintext(rt: &Content) -> String {
rt.text.chars().filter(|&c| c != ISLAND_SLOT).collect()
}
struct Ctx<'a> {
rt: &'a Content,
segments: &'a [Segment],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Segment {
pub start: usize,
pub end: usize,
pub byte_start: usize,
pub byte_end: usize,
pub slots_before: usize,
}
pub fn line_segments(rt: &Content) -> Vec<Segment> {
let mut segs = Vec::with_capacity(rt.lines.len());
let mut start = 0usize;
let mut byte_start = 0usize;
let mut slots_before = 0usize;
let mut line_slots = 0usize;
let mut pos = 0usize;
for (b, c) in rt.text.char_indices() {
if c == '\n' {
segs.push(Segment {
start,
end: pos,
byte_start,
byte_end: b,
slots_before,
});
start = pos + 1;
byte_start = b + 1; slots_before += line_slots;
line_slots = 0;
} else if c == ISLAND_SLOT {
line_slots += 1;
}
pos += 1;
}
segs.push(Segment {
start,
end: pos,
byte_start,
byte_end: rt.text.len(),
slots_before,
});
let total_slots = slots_before + line_slots;
while segs.len() < rt.lines.len() {
segs.push(Segment {
start: pos,
end: pos,
byte_start: rt.text.len(),
byte_end: rt.text.len(),
slots_before: total_slots,
});
}
segs
}
fn emit_block(ctx: &Ctx, range: std::ops::Range<usize>, depth: usize, out: &mut String) {
let lines = &ctx.rt.lines;
let mut i = range.start;
let mut first_block = true;
while i < range.end {
let line = &lines[i];
if line.containers.len() > depth {
let key = &line.containers[depth];
let mut j = i + 1;
while j < range.end
&& lines[j].containers.len() > depth
&& &lines[j].containers[depth] == key
{
j += 1;
}
block_separator(out, first_block);
emit_container(ctx, key, i..j, depth, out);
first_block = false;
i = j;
} else {
let mut j = i + 1;
while j < range.end && lines[j].containers.len() == depth && lines[j].continues {
j += 1;
}
block_separator(out, first_block);
emit_leaf_block(ctx, i..j, out);
first_block = false;
i = j;
}
}
}
fn block_separator(out: &mut String, first_block: bool) {
if !first_block {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
fn emit_container(
ctx: &Ctx,
key: &Container,
range: std::ops::Range<usize>,
depth: usize,
out: &mut String,
) {
let mut inner = String::new();
emit_block(ctx, range, depth + 1, &mut inner);
match key {
Container::ListItem {
ordered,
start,
ordinal,
} => {
let marker = if *ordered {
format!("{}. ", start.saturating_add(*ordinal))
} else {
"- ".to_string()
};
let indent = " ".repeat(marker.len());
prefix_lines(&inner, &marker, &indent, out);
}
Container::Quote => {
prefix_quote(&inner, out);
}
}
}
fn prefix_lines(inner: &str, first: &str, cont: &str, out: &mut String) {
for (idx, line) in inner.split('\n').enumerate() {
if idx == 0 {
out.push_str(first);
out.push_str(line);
} else {
out.push('\n');
if line.is_empty() {
} else {
out.push_str(cont);
out.push_str(line);
}
}
}
}
fn prefix_quote(inner: &str, out: &mut String) {
for (idx, line) in inner.split('\n').enumerate() {
if idx > 0 {
out.push('\n');
}
if line.is_empty() {
out.push('>');
} else {
out.push_str("> ");
out.push_str(line);
}
}
}
fn emit_code(ctx: &Ctx, range: std::ops::Range<usize>, lang: Option<&str>, out: &mut String) {
let mut max_ticks = 0usize;
for i in range.clone() {
max_ticks = max_ticks.max(longest_backtick_run(seg_str(ctx, i)));
}
let fence = "`".repeat(max_ticks.max(2) + 1);
out.push_str(&fence);
if let Some(l) = lang {
out.push_str(l);
}
out.push('\n');
for i in range {
out.push_str(seg_str(ctx, i));
out.push('\n');
}
out.push_str(&fence);
}
fn emit_leaf_block(ctx: &Ctx, range: std::ops::Range<usize>, out: &mut String) {
let first = &ctx.rt.lines[range.start];
match &first.kind {
LineKind::Code { lang } => emit_code(ctx, range, lang.as_deref(), out),
LineKind::Island => {
if let Some(isl) = slot_island(ctx, range.start) {
emit_island(isl, out);
}
}
LineKind::Heading { level } => {
for _ in 0..*level {
out.push('#');
}
out.push(' ');
let mut inline = render_inline(ctx, range.start, false);
if inline.ends_with('#') {
inline.pop();
inline.push_str("\\#");
}
out.push_str(&inline);
}
LineKind::Para => {
let parts: Vec<String> = range.map(|i| render_inline(ctx, i, true)).collect();
out.push_str(&parts.join("\\\n"));
}
LineKind::Rule => out.push_str("---"),
}
}
fn seg_str<'a>(ctx: &'a Ctx, i: usize) -> &'a str {
let seg = &ctx.segments[i];
&ctx.rt.text[seg.byte_start..seg.byte_end]
}
fn slot_island<'a>(ctx: &'a Ctx, i: usize) -> Option<&'a Island> {
ctx.rt.islands.get(ctx.segments[i].slots_before)
}
fn emit_island(isl: &Island, out: &mut String) {
match KnownIslandType::parse(&isl.island_type) {
Some(KnownIslandType::Table) => emit_table(isl, out),
Some(KnownIslandType::Image) => emit_image(isl, out),
None => {
out.push_str(&format!("<!-- island:{} -->", isl.island_type));
}
}
}
fn emit_table(isl: &Island, out: &mut String) {
let header = isl.props.get("header").and_then(|v| v.as_array());
let rows = isl.props.get("rows").and_then(|v| v.as_array());
let aligns = isl.props.get("aligns").and_then(|v| v.as_array());
let cols = header.map(|h| h.len()).unwrap_or(0);
if cols == 0 {
return;
}
out.push_str("| ");
if let Some(h) = header {
out.push_str(&h.iter().map(render_cell_md).collect::<Vec<_>>().join(" | "));
}
out.push_str(" |\n|");
for k in 0..cols {
let a = aligns
.and_then(|a| a.get(k))
.and_then(|v| v.as_str())
.unwrap_or("none");
out.push_str(match a {
"left" => " :--- |",
"center" => " :---: |",
"right" => " ---: |",
_ => " --- |",
});
}
if let Some(rs) = rows {
for row in rs {
if let Some(r) = row.as_array() {
let mut cells: Vec<String> = r.iter().map(render_cell_md).collect();
cells.resize(cols, String::new());
out.push_str("\n| ");
out.push_str(&cells.join(" | "));
out.push_str(" |");
}
}
}
}
fn emit_image(isl: &Island, out: &mut String) {
let url = isl.props.get("url").and_then(|v| v.as_str()).unwrap_or("");
let alt = isl.props.get("alt").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(";
emit_url(url, out);
out.push(')');
}
fn emit_url(url: &str, out: &mut String) {
if url_is_bare_safe(url) {
out.push_str(url);
return;
}
out.push('<');
for c in url.chars() {
if matches!(c, '<' | '>' | '\\' | '&') {
out.push('\\');
}
out.push(c);
}
out.push('>');
}
fn url_is_bare_safe(url: &str) -> bool {
let mut depth: i32 = 0;
for c in url.chars() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth < 0 {
return false;
}
}
'<' | '>' | '\\' | '&' => return false,
c if c.is_whitespace() || c.is_control() => return false,
_ => {}
}
}
depth == 0
}
fn render_inline(ctx: &Ctx, i: usize, escape_leading_block: bool) -> String {
let seg = &ctx.segments[i];
let line_start = seg.start;
let text = seg_str(ctx, i);
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut code_ranges: Vec<(usize, usize)> = Vec::new();
let mut fmt: Vec<(usize, usize, &MarkKind)> = Vec::new();
let mut links: Vec<(usize, usize, &str)> = Vec::new();
for m in &ctx.rt.marks {
let s = m.start.saturating_sub(line_start);
let e = m.end.saturating_sub(line_start);
if m.end <= line_start || m.start >= line_start + n {
continue; }
let s = s.min(n);
let e = e.min(n);
match &m.kind {
MarkKind::Anchor { .. } => {} MarkKind::Code => code_ranges.push((s, e)),
MarkKind::Link { url } => links.push((s, e, url)),
_ => fmt.push((s, e, &m.kind)),
}
}
let escape_punct_at = if escape_leading_block {
let lead_digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
if lead_digits > 0 && lead_digits < n && matches!(chars[lead_digits], '.' | ')') {
Some(lead_digits)
} else {
None
}
} else {
None
};
let slots_before_line = seg.slots_before;
render_marked_core(
&chars,
&code_ranges,
&fmt,
&links,
escape_punct_at,
escape_leading_block,
false, |pos_local| {
let before = slots_before_line
+ chars[..pos_local]
.iter()
.filter(|&&c| c == ISLAND_SLOT)
.count();
ctx.rt.islands.get(before).map(|isl| {
let mut tmp = String::new();
emit_island(isl, &mut tmp);
tmp
})
},
)
}
#[allow(clippy::too_many_arguments)]
fn render_marked_core(
chars: &[char],
code_ranges: &[(usize, usize)],
fmt: &[(usize, usize, &MarkKind)],
links: &[(usize, usize, &str)],
escape_punct_at: Option<usize>,
escape_leading_block: bool,
escape_pipe: bool,
island_markup_at: impl Fn(usize) -> Option<String>,
) -> String {
let n = chars.len();
let mut fmt: Vec<(usize, usize, &MarkKind)> = fmt.to_vec();
let mut atomics: Vec<(usize, usize)> = code_ranges.to_vec();
atomics.extend(links.iter().map(|(s, e, _)| (*s, *e)));
clip_fmt_to_atomic(&mut fmt, &atomics);
clip_asterisk_overlap(&mut fmt);
let sweep = |fmt: &[(usize, usize, &MarkKind)]| -> String {
let mut out = String::new();
let mut stack: Vec<usize> = Vec::new();
let mut pos = 0usize;
while pos <= n {
if let Some(idx) = stack.iter().position(|&fi| fmt[fi].1 == pos) {
let mut reopen: Vec<usize> = Vec::new();
while stack.len() > idx {
let fi = stack.pop().unwrap();
out.push_str(&delim_close(fmt[fi].2));
if fmt[fi].1 != pos {
reopen.push(fi);
}
}
for fi in reopen.into_iter().rev() {
out.push_str(&delim_open(fmt[fi].2));
stack.push(fi);
}
}
let mut opening: Vec<usize> = (0..fmt.len()).filter(|&fi| fmt[fi].0 == pos).collect();
opening.sort_by(|&a, &b| fmt[b].1.cmp(&fmt[a].1));
for fi in opening {
out.push_str(&delim_open(fmt[fi].2));
stack.push(fi);
}
if let Some(&(ls, le, url)) = links.iter().find(|(s, _, _)| *s == pos) {
out.push('[');
out.push_str(&escape_run(&chars[ls..le], escape_pipe));
out.push_str("](");
emit_url(url, &mut out);
out.push(')');
pos = le;
continue;
}
if let Some(&(cs, ce)) = code_ranges.iter().find(|(s, _)| *s == pos) {
let content: String = chars[cs..ce].iter().collect();
let ticks = longest_backtick_run(&content) + 1;
let fence = "`".repeat(ticks.max(1));
out.push_str(&fence);
out.push_str(&content);
out.push_str(&fence);
pos = ce;
continue;
}
if pos < n {
let c = chars[pos];
if c == ISLAND_SLOT {
if let Some(markup) = island_markup_at(pos) {
out.push_str(&markup);
}
} else if Some(pos) == escape_punct_at {
out.push('\\');
out.push(c);
} else {
escape_char_into(c, pos == 0 && escape_leading_block, escape_pipe, &mut out);
}
}
pos += 1;
}
while let Some(fi) = stack.pop() {
out.push_str(&delim_close(fmt[fi].2));
}
out
};
let expected: String = chars.iter().collect();
let is_flanking = |k: &MarkKind| {
matches!(k, MarkKind::Strong | MarkKind::Emph | MarkKind::Strike)
};
let want = format!(",{expected},");
let text_safe = |md: &str| {
crate::import::from_markdown(&format!(",{md},"))
.map(|rt| rt.text == want)
.unwrap_or(false)
};
let mut out = sweep(&fmt);
while fmt.iter().any(|m| is_flanking(m.2)) && !text_safe(&out) {
let Some(i) = fmt.iter().rposition(|m| is_flanking(m.2)) else {
break;
};
fmt.remove(i);
out = sweep(&fmt);
}
out
}
fn clip_fmt_to_atomic(fmt: &mut Vec<(usize, usize, &MarkKind)>, atomics: &[(usize, usize)]) {
for m in fmt.iter_mut() {
clip_range_to_atomic(&mut m.0, &mut m.1, atomics);
}
fmt.retain(|m| m.0 < m.1);
}
pub fn clip_range_to_atomic(start: &mut usize, end: &mut usize, atomics: &[(usize, usize)]) {
for &(cs, ce) in atomics {
if cs < *start && *start < ce {
*start = ce;
}
if cs < *end && *end < ce {
*end = cs;
}
}
}
fn clip_asterisk_overlap(fmt: &mut [(usize, usize, &MarkKind)]) {
let is_ast = |k: &MarkKind| matches!(k, MarkKind::Strong | MarkKind::Emph);
let mut idx: Vec<usize> = (0..fmt.len()).filter(|&i| is_ast(fmt[i].2)).collect();
idx.sort_by(|&a, &b| fmt[a].0.cmp(&fmt[b].0).then(fmt[b].1.cmp(&fmt[a].1)));
let mut open_ends: Vec<usize> = Vec::new();
for &i in &idx {
let (s, mut e, _) = fmt[i];
while open_ends.last().is_some_and(|&end| end <= s) {
open_ends.pop();
}
if let Some(&parent_end) = open_ends.last() {
if parent_end < e {
e = parent_end;
fmt[i].1 = e;
}
}
open_ends.push(e);
}
}
fn render_cell_md(v: &serde_json::Value) -> String {
let (text, marks) = crate::serial::parse_cell(v);
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut code_ranges: Vec<(usize, usize)> = Vec::new();
let mut fmt: Vec<(usize, usize, &MarkKind)> = Vec::new();
let mut links: Vec<(usize, usize, &str)> = Vec::new();
for m in &marks {
if m.start >= n {
continue;
}
let s = m.start;
let e = m.end.min(n);
if s >= e {
continue;
}
match &m.kind {
MarkKind::Anchor { .. } => {}
MarkKind::Code => code_ranges.push((s, e)),
MarkKind::Link { url } => links.push((s, e, url)),
_ => fmt.push((s, e, &m.kind)),
}
}
render_marked_core(
&chars,
&code_ranges,
&fmt,
&links,
None,
false,
true,
|_| None,
)
}
fn delim_open(kind: &MarkKind) -> String {
match kind {
MarkKind::Strong => "**".into(),
MarkKind::Emph => "*".into(),
MarkKind::Underline => "<u>".into(),
MarkKind::Strike => "~~".into(),
_ => String::new(),
}
}
fn delim_close(kind: &MarkKind) -> String {
match kind {
MarkKind::Strong => "**".into(),
MarkKind::Emph => "*".into(),
MarkKind::Underline => "</u>".into(),
MarkKind::Strike => "~~".into(),
_ => String::new(),
}
}
fn escape_run(chars: &[char], escape_pipe: bool) -> String {
let mut s = String::new();
for (i, c) in chars.iter().enumerate() {
escape_char_into(*c, i == 0, escape_pipe, &mut s);
}
s
}
fn escape_char_into(c: char, leading: bool, escape_pipe: bool, out: &mut String) {
let esc: &str = match c {
'\\' => "\\\\",
'*' => "\\*",
'_' => "\\_",
'`' => "\\`",
'[' => "\\[",
']' => "\\]",
'<' => "\\<",
'~' => "\\~",
'&' => "\\&",
'|' if escape_pipe => "\\|",
'#' if leading => "\\#",
'>' if leading => "\\>",
'-' if leading => "\\-",
'+' if leading => "\\+",
other => {
out.push(other);
return;
}
};
out.push_str(esc);
}
fn longest_backtick_run(s: &str) -> usize {
let mut max = 0;
let mut run = 0;
for c in s.chars() {
if c == '`' {
run += 1;
max = max.max(run);
} else {
run = 0;
}
}
max
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::from_markdown;
use crate::model::{Line, Loss, Mark};
fn round_trips(md: &str) {
let rt = from_markdown(md).unwrap();
let md2 = to_markdown(&rt);
let rt2 = from_markdown(&md2).unwrap();
assert_eq!(
rt, rt2,
"content not a fixed point.\n in: {md:?}\n mid: {md2:?}"
);
}
#[test]
fn plaintext_drops_marks_and_islands() {
let rt = marked(
"bold text",
vec![Mark { start: 0, end: 4, kind: MarkKind::Strong }],
);
assert_eq!(to_plaintext(&rt), "bold text");
let mut rt = Content {
text: format!("see {ISLAND_SLOT} here"),
lines: vec![Line { kind: LineKind::Para, containers: vec![], continues: false }],
marks: vec![],
islands: vec![Island {
id: String::new(),
island_type: "image".into(),
props: serde_json::Value::Null,
loss: Loss::Unrepresentable,
}],
};
rt.normalize();
assert_eq!(to_plaintext(&rt), "see here");
}
fn marked(text: &str, marks: Vec<Mark>) -> Content {
let mut rt = Content {
text: text.to_string(),
lines: vec![Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
}],
marks,
islands: vec![],
};
rt.normalize();
assert_eq!(rt.validate(), Ok(()), "content invariants");
rt
}
#[test]
fn single_constructs_round_trip() {
for (label, md) in [
("paragraph", "Hello world"),
("two_paragraphs", "one\n\ntwo"),
("marks", "a **b** _c_ ~~d~~ <u>e</u>"),
("heading", "## Title here"),
("inline_code", "run `cargo test` now"),
("bullet_list", "- a\n- b\n- c"),
("ordered_list", "3. a\n4. b"),
("multi_paragraph_item", "- first\n\n second"),
("blockquote", "> quoted text"),
("link", "see [our site](https://example.com) now"),
("table", "| a | b |\n| --- | --- |\n| 1 | 2 |"),
("image", "see  here"),
] {
println!("construct: {label}");
round_trips(md);
}
}
#[test]
fn nested_marks() {
round_trips("**bold _and italic_**");
}
#[test]
fn code_block() {
round_trips("```rust\nfn a() {}\nfn b() {}\n```");
}
#[test]
fn thematic_break() {
round_trips("one\n\n***\n\ntwo");
}
#[test]
fn thematic_break_canonicalizes_to_dashes() {
for src in ["***", "___", "- - -"] {
let rt = from_markdown(&format!("one\n\n{src}\n\ntwo")).unwrap();
let md = to_markdown(&rt);
assert!(md.contains("\n\n---\n\n"), "source: {src}, got: {md:?}");
}
}
#[test]
fn literal_asterisks_escaped() {
round_trips("2 * 3 = 6 and a_b_c");
}
#[test]
fn hard_break_round_trips() {
round_trips("line one\\\nline two");
}
#[test]
fn hard_break_in_list_item() {
round_trips("- one\\\ntwo\n- three");
}
#[test]
fn leading_ordered_marker_escaped() {
let mut rt = from_markdown("x").unwrap();
rt.text = "1. not a list".into();
let md = to_markdown(&rt);
let back = from_markdown(&md).unwrap();
assert_eq!(back.lines[0].kind, LineKind::Para);
assert!(back.lines[0].containers.is_empty());
assert_eq!(back, rt);
}
#[test]
fn table_with_formatted_cells_round_trips() {
round_trips("| Name | Note |\n| --- | --- |\n| **bold** | _italic_ |");
round_trips("| A |\n| --- |\n| **b** and _i_ `c` [d](https://e.com) ~~e~~ |");
round_trips("| A |\n| --- |\n| <u>under</u> |");
round_trips("| A |\n| --- |\n| a \\| b |");
}
#[test]
fn formatted_cell_marks_are_structured_not_reparsed() {
let rt = from_markdown("| H |\n| --- |\n| **bold** |").unwrap();
let cell = &rt.islands[0].props["rows"][0][0];
assert_eq!(cell["text"], "bold");
assert_eq!(cell["marks"][0]["type"], "strong");
assert_eq!(cell["marks"][0]["start"], 0);
assert_eq!(cell["marks"][0]["end"], 4);
assert!(to_markdown(&rt).contains("**bold**"));
}
#[test]
fn known_hard_break_limits() {
let rt = from_markdown("**one\\\ntwo**").unwrap();
let rt2 = from_markdown(&to_markdown(&rt)).unwrap();
assert!(
rt != rt2,
"if this ever round-trips, promote it out of the known-limits list"
);
assert_eq!(rt2.marks.len(), 2, "mark split across the hard break");
}
#[test]
fn anchor_marks_omitted_but_text_survives() {
let mut rt = from_markdown("comment target here").unwrap();
rt.marks.push(Mark {
start: 8,
end: 14,
kind: MarkKind::Anchor { id: "c1".into() },
});
rt.normalize();
let md = to_markdown(&rt);
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.text, "comment target here");
assert!(!md.contains("c1"));
}
#[test]
fn overlapping_asterisk_marks_stay_text_safe() {
let rt = marked(
"abcdef",
vec![
Mark {
start: 0,
end: 4,
kind: MarkKind::Strong,
},
Mark {
start: 2,
end: 6,
kind: MarkKind::Emph,
},
],
);
let md = to_markdown(&rt);
assert_eq!(md, "**ab*cd***ef", "balanced, no literal `**` leak");
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.text, "abcdef");
assert_eq!(
rt2.marks,
vec![
Mark {
start: 0,
end: 4,
kind: MarkKind::Strong
},
Mark {
start: 2,
end: 4,
kind: MarkKind::Emph
},
]
);
}
#[test]
fn overlapping_distinct_delim_marks_round_trip_exactly() {
for (k1, k2) in [
(MarkKind::Strong, MarkKind::Strike),
(MarkKind::Strike, MarkKind::Strong),
(MarkKind::Emph, MarkKind::Strike),
(MarkKind::Underline, MarkKind::Emph),
(MarkKind::Strong, MarkKind::Underline),
] {
let rt = marked(
"abcdef",
vec![
Mark {
start: 0,
end: 4,
kind: k1.clone(),
},
Mark {
start: 2,
end: 6,
kind: k2.clone(),
},
],
);
let md = to_markdown(&rt);
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt, rt2, "{k1:?}+{k2:?} overlap not a fixed point: {md:?}");
}
}
#[test]
fn wrap_over_code_stays_balanced() {
let rt = marked(
"abcdef",
vec![
Mark {
start: 0,
end: 4,
kind: MarkKind::Strong,
},
Mark {
start: 2,
end: 6,
kind: MarkKind::Code,
},
],
);
let md = to_markdown(&rt);
assert_eq!(md, "**ab**`cdef`");
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.text, "abcdef");
}
#[test]
fn ampersand_and_entities_round_trip() {
round_trips("a & b");
round_trips("copyright \\© sign");
let rt = from_markdown("\\&").unwrap();
assert_eq!(rt.text, "&");
let md = to_markdown(&rt);
assert!(md.contains("\\&"), "the `&` must be escaped, got {md:?}");
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.text, "&", "entity-shaped text must not decode");
assert_eq!(rt, rt2);
}
#[test]
fn heading_trailing_hash_round_trips() {
let rt = from_markdown("# a \\#").unwrap();
assert_eq!(rt.text, "a #");
let md = to_markdown(&rt);
assert!(md.contains("\\#"), "trailing `#` must be escaped, got {md:?}");
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.text, "a #", "trailing `#` must survive");
assert_eq!(rt, rt2);
round_trips("# heading \\#\\#");
round_trips("## title\\#");
}
#[test]
fn image_alt_specials_round_trip() {
round_trips("see ![a\\]b](x.png) here");
round_trips("see  here");
round_trips("see  here");
round_trips("see  here");
let rt = from_markdown("see ![a\\]b](x.png) here").unwrap();
assert_eq!(rt.islands.len(), 1, "one image island");
assert_eq!(rt.islands[0].props["alt"], "a]b");
let md = to_markdown(&rt);
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.islands.len(), 1, "image survived, got md {md:?}");
assert_eq!(rt2.islands[0].props["alt"], "a]b");
}
#[test]
fn link_url_with_space_round_trips() {
round_trips("a [t](<foo bar>) b");
let rt = from_markdown("a [t](<foo bar>) b").unwrap();
assert!(
rt.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Link { url } if url == "foo bar")),
"link url with space imported"
);
let md = to_markdown(&rt);
assert!(md.contains("<foo bar>"), "url angle-wrapped, got {md:?}");
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt, rt2);
}
#[test]
fn url_specials_round_trip() {
round_trips("see [t](https://en.wikipedia.org/wiki/Rust_(programming_language)) x");
round_trips("see  here");
round_trips("see [t](<a )b>) x");
round_trips("see [t](<a&b>) x");
round_trips("see [t](<a\\<b\\>c>) x");
round_trips("see [t](<a\\\\b>) x");
}
#[test]
fn emit_url_bare_when_safe() {
let mut bare = String::new();
emit_url("https://ex.com/a(b)c", &mut bare);
assert_eq!(bare, "https://ex.com/a(b)c", "balanced parens stay bare");
let mut wrapped = String::new();
emit_url("a b", &mut wrapped);
assert_eq!(wrapped, "<a b>", "space forces the wrap");
let mut esc = String::new();
emit_url("a&<\\b", &mut esc);
assert_eq!(esc, "<a\\&\\<\\\\b>", "specials escaped inside the wrap");
}
#[test]
fn ordered_list_marker_saturates_on_overflow() {
let json = format!(
r#"{{"text":"x","lines":[{{"kind":"para","containers":[{{"container":"list_item","ordered":true,"start":{},"ordinal":5}}]}}],"marks":[],"islands":[]}}"#,
u64::MAX
);
let rt = Content::from_canonical_json(&json).unwrap();
let md = to_markdown(&rt);
assert!(
md.contains(&format!("{}. ", u64::MAX)),
"marker saturates to u64::MAX: {md:?}"
);
}
}