use crate::island::IslandType;
use crate::model::{
Container, Island, LineKind, Mark, MarkKind, Content, Normalized, Usv, ISLAND_SLOT,
};
pub fn to_markdown(rt: &Normalized) -> 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
}
struct Frame<'a> {
container: Option<&'a Container>,
at: usize,
range: std::ops::Range<usize>,
depth: usize,
first_block: bool,
buf: String,
}
impl<'a> Frame<'a> {
fn open(container: Option<&'a Container>, range: std::ops::Range<usize>, depth: usize) -> Self {
Frame {
container,
at: range.start,
range,
depth,
first_block: true,
buf: String::new(),
}
}
}
fn emit_block(ctx: &Ctx, range: std::ops::Range<usize>, depth: usize, out: &mut String) {
let lines = &ctx.rt.lines;
let mut stack = vec![Frame::open(None, range, depth)];
while let Some(frame) = stack.last_mut() {
if frame.at < frame.range.end {
let i = frame.at;
block_separator(&mut frame.buf, frame.first_block);
frame.first_block = false;
if lines[i].containers.len() > frame.depth {
let item = crate::traverse::items(lines, i..frame.range.end, frame.depth)
.next()
.expect("a line with a container at `depth` opens an item");
frame.at = item.range.end;
let child = Frame::open(Some(item.container), item.range, frame.depth + 1);
stack.push(child);
} else {
let seg = crate::traverse::segment(lines, i..frame.range.end, frame.depth);
frame.at = seg.end;
emit_leaf_block(ctx, seg, &mut frame.buf);
}
continue;
}
let done = stack.pop().expect("`last_mut` just yielded this frame");
match stack.last_mut() {
Some(parent) => {
let key = done
.container
.expect("only the root frame opens without a container");
close_container(key, &done.buf, &mut parent.buf);
}
None => out.push_str(&done.buf),
}
}
}
fn block_separator(out: &mut String, first_block: bool) {
if !first_block {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
fn close_container(key: &Container, inner: &str, out: &mut String) {
match key {
Container::ListItem {
ordered,
start,
ordinal,
instance,
} => {
let marker = if *ordered {
let n = start.saturating_add(*ordinal);
if instance % 2 == 0 {
format!("{n}. ")
} else {
format!("{n}) ")
}
} else if instance % 2 == 0 {
"- ".to_string()
} else {
"+ ".to_string()
};
let indent = " ".repeat(marker.len());
let head = inner.split('\n').next().unwrap_or("");
if is_thematic_break(&format!("{marker}{head}")) {
out.push_str(marker.trim_end());
out.push('\n');
prefix_lines(inner, &indent, &indent, out);
} else {
prefix_lines(inner, &marker, &indent, out);
}
}
Container::Quote { .. } => {
prefix_quote(inner, out);
}
}
}
fn is_thematic_break(line: &str) -> bool {
let rest = line.trim_start_matches(' ');
if line.len() - rest.len() > 3 {
return false;
}
let Some(c) = rest.chars().next().filter(|c| matches!(c, '-' | '_' | '*')) else {
return false;
};
let mut n = 0;
for ch in rest.chars() {
if ch == c {
n += 1;
} else if ch != ' ' && ch != '\t' {
return false;
}
}
n >= 3
}
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 isl.island_type {
IslandType::Table => emit_table(isl, out),
IslandType::Image => emit_image(isl, out),
}
}
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() {
match c {
'\n' => out.push_str("%0A"),
'\r' => out.push_str("%0D"),
'<' | '>' | '\\' | '&' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out.push('>');
}
pub(crate) fn url_is_writable(url: &str) -> bool {
!url.contains(['\n', '\r'])
}
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 (code_ranges, fmt, links) = bucket_marks(&ctx.rt.marks, line_start, n, false);
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 markup = String::new();
emit_island(isl, &mut markup);
markup
})
},
)
}
fn bucket_marks(
marks: &[Mark],
line_start: usize,
n: usize,
drop_empty: bool,
) -> (
Vec<(usize, usize)>,
Vec<(usize, usize, &MarkKind)>,
Vec<(usize, usize, &str)>,
) {
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.end <= line_start || m.start >= line_start + n {
continue;
}
let s = m.start.saturating_sub(line_start).min(n);
let e = m.end.saturating_sub(line_start).min(n);
if drop_empty && s >= e {
continue;
}
match &m.kind {
MarkKind::Anchor { .. } => {}
MarkKind::Code => code_ranges.push((s, e)),
MarkKind::Link { url } => links.push((s, e, url.as_str())),
_ => fmt.push((s, e, &m.kind)),
}
}
(code_ranges, fmt, links)
}
#[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 code_ranges: Vec<(usize, usize)> = code_ranges
.iter()
.flat_map(|&(s, e)| split_around_slots(chars, s, e))
.collect();
code_ranges.sort_unstable();
let code_ranges = &code_ranges[..];
let mut links: Vec<(usize, usize, &str)> = links.to_vec();
links.sort_by_key(|&(s, _, _)| s);
let links = &links[..];
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 lead_edge = chars
.iter()
.take_while(|c| edge_space_ref(**c).is_some())
.count();
let trail_edge = n - chars[lead_edge..]
.iter()
.rev()
.take_while(|c| edge_space_ref(**c).is_some())
.count();
let ast_last = |k: &MarkKind| u8::from(matches!(k, MarkKind::Strong | MarkKind::Emph));
let mut by_start: Vec<usize> = (0..fmt.len()).collect();
by_start.sort_by(|&a, &b| {
fmt[a].0
.cmp(&fmt[b].0)
.then(fmt[b].1.cmp(&fmt[a].1))
.then(ast_last(fmt[a].2).cmp(&ast_last(fmt[b].2)))
});
let sweep = |keep: &[bool], d: Delims| -> String {
let mut out = String::new();
let mut stack: Vec<usize> = Vec::new();
let (mut oi, mut li, mut ci) = (0usize, 0usize, 0usize);
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, d));
if fmt[fi].1 != pos {
reopen.push(fi);
}
}
for fi in reopen.into_iter().rev() {
out.push_str(delim_open(fmt[fi].2, d));
stack.push(fi);
}
}
while oi < by_start.len() && fmt[by_start[oi]].0 < pos {
oi += 1;
}
while oi < by_start.len() && fmt[by_start[oi]].0 == pos {
let fi = by_start[oi];
oi += 1;
if !keep[fi] {
continue;
}
out.push_str(delim_open(fmt[fi].2, d));
stack.push(fi);
}
while li < links.len() && links[li].0 < pos {
li += 1;
}
if let Some(&(ls, le, url)) = links.get(li).filter(|l| l.0 == pos) {
out.push('[');
for (i, &c) in chars[ls..le].iter().enumerate() {
if c == ISLAND_SLOT {
if let Some(slot) = island_markup_at(ls + i) {
out.push_str(&slot);
}
} else {
escape_char_into(c, i == 0, escape_pipe, &mut out);
}
}
out.push_str("](");
emit_url(url, &mut out);
out.push(')');
pos = le;
continue;
}
while ci < code_ranges.len() && code_ranges[ci].0 < pos {
ci += 1;
}
if let Some(&(cs, ce)) = code_ranges.get(ci).filter(|r| r.0 == pos) {
let content: String = chars[cs..ce].iter().collect();
let ticks = longest_backtick_run(&content) + 1;
let fence = "`".repeat(ticks.max(1));
let pad = content.starts_with('`')
|| content.ends_with('`')
|| (content.starts_with(' ')
&& content.ends_with(' ')
&& content.chars().any(|c| c != ' '));
out.push_str(&fence);
if pad {
out.push(' ');
}
out.push_str(&content);
if pad {
out.push(' ');
}
out.push_str(&fence);
pos = ce;
continue;
}
if pos < n {
let c = chars[pos];
if c == ISLAND_SLOT {
if let Some(slot) = island_markup_at(pos) {
out.push_str(&slot);
}
} else if Some(pos) == escape_punct_at {
out.push('\\');
out.push(c);
} else if let Some(esc) = edge_space_ref(c)
&& (pos < lead_edge || pos >= trail_edge)
{
out.push_str(esc);
} 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, d));
}
out
};
let is_flanking = |k: &MarkKind| {
matches!(k, MarkKind::Strong | MarkKind::Emph | MarkKind::Strike)
};
let all = vec![true; fmt.len()];
if !fmt.iter().any(|m| is_flanking(m.2)) {
return sweep(&all, DELIM_SPELLINGS[0]);
}
let mut expected = String::with_capacity(n);
let mut kept: Vec<Usv> = Vec::with_capacity(n + 1);
let mut at = 1;
for (i, &c) in chars.iter().enumerate() {
kept.push(at);
if c != ISLAND_SLOT || island_markup_at(i).is_some() {
expected.push(c);
at += 1;
}
}
kept.push(at);
let want = format!(",{expected},");
let want_marks = |keep: &[bool]| {
crate::model::normalize_marks(
fmt.iter()
.enumerate()
.filter(|&(i, m)| keep[i] && is_flanking(m.2))
.map(|(_, &(s, e, k))| Mark::new(kept[s], kept[e], k.clone()))
.collect(),
)
};
let probe = |md: &str, want_marks: &[Mark]| -> Option<bool> {
let rt = crate::import::from_markdown(&format!(",{md},")).ok()?;
if rt.text != want {
return None;
}
let got: Vec<&Mark> = rt.marks.iter().filter(|m| is_flanking(&m.kind)).collect();
Some(got.into_iter().eq(want_marks.iter()))
};
let intent = want_marks(&all);
for &d in &DELIM_SPELLINGS {
let cand = sweep(&all, d);
if probe(&cand, &intent) == Some(true) {
return cand;
}
}
let cands: Vec<usize> = (0..fmt.len()).filter(|&i| is_flanking(fmt[i].2)).collect();
let mask_of = |keep: &[usize]| -> Vec<bool> {
let mut mask: Vec<bool> = fmt.iter().map(|m| !is_flanking(m.2)).collect();
for &i in keep {
mask[i] = true;
}
mask
};
let render = |keep: &[usize]| -> String { sweep(&mask_of(keep), DELIM_SPELLINGS[0]) };
let survives = |md: &str, keep: &[usize]| probe(md, &want_marks(&mask_of(keep))) == Some(true);
let mut out = render(&[]);
if cands.len() == 1 || !survives(&out, &[]) {
return out;
}
let mut kept: Vec<usize> = Vec::new();
let mid = cands.len() / 2;
let mut work: Vec<(usize, usize, usize)> = vec![(mid, cands.len(), mid), (0, mid, usize::MAX)];
let mut budget = PROBE_BUDGET;
while let Some((lo, hi, known_bad_at)) = work.pop() {
let split = |work: &mut Vec<_>, kept_len: usize| {
if hi - lo > 1 {
let mid = lo + (hi - lo) / 2;
work.push((mid, hi, kept_len + (mid - lo)));
work.push((lo, mid, usize::MAX));
}
};
if known_bad_at == kept.len() {
split(&mut work, kept.len());
continue;
}
if budget == 0 {
break;
}
budget -= 1;
let trial: Vec<usize> = kept.iter().chain(&cands[lo..hi]).copied().collect();
let md = render(&trial);
if survives(&md, &trial) {
kept = trial;
out = md;
} else {
split(&mut work, kept.len());
}
}
out
}
const PROBE_BUDGET: usize = 64;
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 split_around_slots(chars: &[char], start: usize, end: usize) -> Vec<(usize, usize)> {
let end = end.min(chars.len());
if !chars[start.min(end)..end].contains(&ISLAND_SLOT) {
return vec![(start, end)];
}
let mut out = Vec::new();
let mut run = start;
for (i, _) in chars[start..end]
.iter()
.enumerate()
.map(|(i, c)| (start + i, c))
.filter(|&(_, &c)| c == ISLAND_SLOT)
{
if run < i {
out.push((run, i));
}
run = i + 1;
}
if run < end {
out.push((run, end));
}
out
}
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 (code_ranges, fmt, links) = bucket_marks(&marks, 0, chars.len(), true);
render_marked_core(
&chars,
&code_ranges,
&fmt,
&links,
None,
false,
true,
|_| None,
)
}
#[derive(Clone, Copy)]
struct Delims {
strong: &'static str,
emph: &'static str,
}
const DELIM_SPELLINGS: [Delims; 4] = [
Delims { strong: "**", emph: "*" },
Delims { strong: "__", emph: "*" },
Delims { strong: "**", emph: "_" },
Delims { strong: "__", emph: "_" },
];
fn delim_open(kind: &MarkKind, d: Delims) -> &'static str {
match kind {
MarkKind::Strong => d.strong,
MarkKind::Emph => d.emph,
MarkKind::Underline => "<u>",
MarkKind::Strike => "~~",
_ => "",
}
}
fn delim_close(kind: &MarkKind, d: Delims) -> &'static str {
match kind {
MarkKind::Strong => d.strong,
MarkKind::Emph => d.emph,
MarkKind::Underline => "</u>",
MarkKind::Strike => "~~",
_ => "",
}
}
fn edge_space_ref(c: char) -> Option<&'static str> {
match c {
' ' => Some(" "),
'\t' => Some("	"),
_ => None,
}
}
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 => "\\+",
'=' 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 stored(text: &str, containers: Vec<Vec<Container>>) -> Normalized {
let lines = containers
.into_iter()
.map(|c| {
let mut l = crate::model::Line::new(LineKind::Para);
l.containers = c;
l
})
.collect();
let rt = Content::new(text.to_string(), lines).into_normalized();
rt.validate().expect("stored content validates");
rt
}
#[test]
fn nesting_past_what_validate_allows_projects_rather_than_overflowing() {
const DEPTH: usize = 10_000;
let mut line = Line::new(LineKind::Para);
line.containers = vec![Container::Quote { instance: 0 }; DEPTH];
let rt = Content::new("x".to_string(), vec![line]).into_normalized();
assert!(rt.validate().is_err(), "the shape `validate` refuses");
assert_eq!(to_markdown(&rt), format!("{}x", "> ".repeat(DEPTH)));
}
#[test]
fn coincident_marks_project_in_the_stored_order() {
let rt = Content::new("x".to_string(), vec![Line::new(LineKind::Para)])
.with_marks(vec![
Mark::new(0, 1, MarkKind::Strong),
Mark::new(0, 1, MarkKind::Strike),
])
.into_normalized();
rt.validate().expect("validates");
assert_eq!(to_markdown(&rt), "~~**x**~~");
assert_eq!(from_markdown("**~~x~~**").unwrap(), rt);
}
fn li(ordinal: u64, instance: u64) -> Vec<Container> {
vec![Container::ListItem {
ordered: false,
start: 1,
ordinal,
instance,
}]
}
fn oli(ordinal: u64, instance: u64) -> Vec<Container> {
vec![Container::ListItem {
ordered: true,
start: 1,
ordinal,
instance,
}]
}
#[test]
fn adjacent_sibling_containers_project_and_return() {
let cases: &[(Normalized, &str)] = &[
(stored("a\nb", vec![li(0, 0), li(0, 1)]), "- a\n\n+ b"),
(stored("a\nb", vec![li(0, 0), li(0, 0)]), "- a\n\n b"),
(
stored("a\nb\nc", vec![li(0, 0), li(0, 1), li(1, 1)]),
"- a\n\n+ b\n\n+ c",
),
(
stored(
"a\nb",
vec![
vec![Container::Quote { instance: 0 }],
vec![Container::Quote { instance: 1 }],
],
),
"> a\n\n> b",
),
(
stored(
"a\nb",
vec![
vec![Container::Quote { instance: 0 }],
vec![Container::Quote { instance: 0 }],
],
),
"> a\n>\n> b",
),
];
for (rt, expected) in cases {
let md = to_markdown(rt);
assert_eq!(&md, expected);
assert_eq!(&from_markdown(&md).unwrap(), rt, "{md:?} did not return");
}
}
#[test]
fn alternate_markers_do_not_collide_with_a_rule() {
let mut rt = stored("a\n\nb", vec![li(0, 0), li(0, 1), li(1, 1)]).into_content();
rt.lines[1].kind = LineKind::Rule;
let rt = rt.into_normalized();
rt.validate().expect("validates");
assert_eq!(to_markdown(&rt), "- a\n\n+ ***\n\n+ b");
assert_eq!(from_markdown(&to_markdown(&rt)).unwrap(), rt);
let mut rt = stored("a\n\nb", vec![oli(0, 0), oli(0, 1), oli(1, 1)]).into_content();
rt.lines[1].kind = LineKind::Rule;
let rt = rt.into_normalized();
assert_eq!(to_markdown(&rt), "1. a\n\n1) ***\n\n2) b");
assert_eq!(from_markdown(&to_markdown(&rt)).unwrap(), rt);
}
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 link_over_island_slot_round_trips() {
round_trips("[](https://e.com)");
round_trips("[a  b](https://e.com)");
assert_eq!(
to_markdown(&from_markdown("[](https://e.com)").unwrap()),
"[](https://e.com)"
);
}
#[test]
fn code_mark_over_island_slot_keeps_the_island() {
let mut rt = from_markdown("a  b").unwrap().into_content();
rt.marks.push(Mark { start: 0, end: 5, kind: MarkKind::Code });
let rt = rt.into_normalized();
assert_eq!(rt.validate(), Ok(()));
let md = to_markdown(&rt);
assert_eq!(md, "`a `` b`");
let rt2 = from_markdown(&md).unwrap();
assert_eq!(rt2.text, rt.text);
assert_eq!(rt2.islands.len(), 1);
}
#[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 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: IslandType::Image,
props: serde_json::Value::Null,
loss: Loss::Unrepresentable,
}],
}
.into_normalized();
assert_eq!(to_plaintext(&rt), "see here");
}
fn marked(text: &str, marks: Vec<Mark>) -> Normalized {
let rt = Content {
text: text.to_string(),
lines: vec![Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
}],
marks,
islands: vec![],
}
.into_normalized();
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"),
("heading_item", "- # Title\n\n body text"),
("rule_item", "* ---"),
("thematic_break", "one\n\n***\n\ntwo"),
("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_canonicalizes_to_stars() {
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 rule_opening_a_list_item_keeps_its_item() {
for md in ["* ---", "+ ---", "- ***", "- ___", "- - ***", "- > ***"] {
round_trips(md);
}
assert_eq!(to_markdown(&from_markdown("* ---").unwrap()), "- ***");
round_trips("1. ---");
round_trips("- one\n\n ---");
round_trips("- a\n- ***\n- c");
}
#[test]
fn a_marker_run_that_spells_a_rule_breaks_its_line() {
for md in ["+ + +", "+ + + + +", "> + + +", "+ + +\n + a", "+ + + a"] {
round_trips(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().into_content();
rt.text = "1. not a list".into();
let rt = rt.into_normalized();
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);
}
fn hard_break_block(text: &str) -> Normalized {
let lines = (0..text.split('\n').count())
.map(|i| Line::new(LineKind::Para).with_continues(i > 0))
.collect();
let rt = Content::new(text.to_string(), lines).into_normalized();
assert_eq!(rt.validate(), Ok(()), "hand-built content invalid");
rt
}
#[test]
fn edge_whitespace_survives_export() {
const TEXTS: &[&str] = &[
" foo",
" foo",
"\tfoo",
"foo ",
" foo ",
" ",
" - item",
" # not a heading",
" 1. not a list",
" > not a quote",
"a # ",
];
for text in TEXTS {
for containers in [vec![], vec![Container::Quote { instance: 0 }], li(0, 0)] {
let rt = stored(text, vec![containers.clone()]);
let md = to_markdown(&rt);
assert_eq!(
&from_markdown(&md).unwrap(),
&rt,
"{text:?} under {containers:?} did not return: {md:?}"
);
}
let mut rt = stored(text, vec![vec![]]).into_content();
rt.lines[0].kind = LineKind::Heading { level: 2 };
let rt = rt.into_normalized();
let md = to_markdown(&rt);
assert_eq!(
&from_markdown(&md).unwrap(),
&rt,
"heading {text:?} did not return: {md:?}"
);
}
}
#[test]
fn edge_whitespace_survives_a_hard_break() {
for text in ["abc \n def", " \nabc", "abc\n ", "\tabc\ndef\t"] {
let rt = hard_break_block(text);
let md = to_markdown(&rt);
assert_eq!(&from_markdown(&md).unwrap(), &rt, "{text:?} → {md:?}");
}
}
#[test]
fn a_setext_underline_on_a_continuation_line_stays_text() {
for text in [
"abc\n===", "abc\n=", "abc\n=== ", "abc\n---", "abc\n= b", "abc\n ---",
"abc\n ===",
] {
let rt = hard_break_block(text);
let md = to_markdown(&rt);
assert_eq!(&from_markdown(&md).unwrap(), &rt, "{text:?} → {md:?}");
}
}
#[test]
fn a_marked_line_with_edge_whitespace_keeps_its_delimiters_out_of_the_text() {
let rt = marked(" foo", vec![Mark::new(4, 7, MarkKind::Strong)]);
let back = from_markdown(&to_markdown(&rt)).unwrap();
assert_eq!(back.text, " foo");
assert_eq!(back, rt);
}
#[test]
fn table_cell_edge_whitespace_round_trips() {
let cell = |t: &str| serde_json::json!({"marks": [], "text": t});
let rt = Content {
text: ISLAND_SLOT.to_string(),
lines: vec![Line::new(LineKind::Island)],
marks: vec![],
islands: vec![
Island::new("isl-0".into(), IslandType::Table).with_props(serde_json::json!({
"aligns": ["none"],
"header": [cell(" h ")],
"rows": [[cell(" a")], [cell("b ")], [cell(" ")]],
})),
],
}
.into_normalized();
assert_eq!(rt.validate(), Ok(()), "table island invalid");
let md = to_markdown(&rt);
assert_eq!(&from_markdown(&md).unwrap(), &rt, "cell edges lost: {md:?}");
}
#[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**"));
}
fn with_islands(text: &str, islands: Vec<Island>) -> Normalized {
let rt = Content {
text: text.to_string(),
lines: vec![Line::new(LineKind::Para)],
marks: vec![],
islands,
}
.into_normalized();
assert_eq!(rt.validate(), Ok(()), "hand-built content invalid");
rt
}
fn table() -> Island {
Island::new("isl-0".into(), IslandType::Table).with_props(serde_json::json!({
"aligns": ["none"],
"header": [{"marks": [], "text": "h"}],
"rows": [[{"marks": [], "text": "c"}]],
}))
}
#[test]
fn a_block_island_inside_a_paragraph_takes_a_line_of_its_own() {
let rt = Content {
text: format!("a{ISLAND_SLOT}bold"),
lines: vec![Line::new(LineKind::Para)],
marks: vec![Mark::new(2, 6, MarkKind::Strong)],
islands: vec![table()],
}
.into_normalized();
assert_eq!(rt.validate(), Ok(()), "hand-built content invalid");
assert_eq!(rt.text, format!("a\n{ISLAND_SLOT}\nbold"), "the mint left it inline");
assert_eq!(rt.lines[1].kind, LineKind::Island);
assert_eq!(rt.marks, vec![Mark::new(4, 8, MarkKind::Strong)]);
let md = to_markdown(&rt);
assert_eq!(from_markdown(&md).unwrap(), rt, "{md:?}");
}
#[test]
fn a_hard_break_after_a_broken_line_keeps_its_text() {
let mut second = Line::new(LineKind::Para);
second.continues = true;
let rt = Content {
text: format!("a{ISLAND_SLOT}\nmore"),
lines: vec![Line::new(LineKind::Para), second],
marks: vec![],
islands: vec![table()],
}
.into_normalized();
assert_eq!(rt.validate(), Ok(()), "hand-built content invalid");
assert_eq!(rt.text, format!("a\n{ISLAND_SLOT}\nmore"));
assert!(!rt.lines[2].continues, "continuation into a block island");
let md = to_markdown(&rt);
let back = from_markdown(&md).unwrap();
assert_eq!(back.text, format!("a\n{ISLAND_SLOT}\nmore"), "{md:?}");
}
#[test]
fn a_mark_leaking_around_an_image_slot_is_still_dropped() {
let image = || {
Island::new("isl-0".into(), IslandType::Image)
.with_props(serde_json::json!({"alt": "a", "url": "u"}))
};
let over_slot = Content {
text: format!("a{ISLAND_SLOT}b"),
lines: vec![Line::new(LineKind::Para)],
marks: vec![Mark::new(1, 2, MarkKind::Strong)],
islands: vec![image()],
}
.into_normalized();
let md = to_markdown(&over_slot);
assert_eq!(md, "ab");
let back = from_markdown(&md).unwrap();
assert_eq!(back.text, over_slot.text);
assert_eq!(back.islands.len(), 1);
assert!(back.marks.is_empty(), "leaking mark kept: {md:?}");
let before_slot = Content {
text: format!("a{ISLAND_SLOT}b"),
lines: vec![Line::new(LineKind::Para)],
marks: vec![Mark::new(0, 1, MarkKind::Strong)],
islands: vec![image()],
}
.into_normalized();
let md = to_markdown(&before_slot);
assert_eq!(md, "**a**b");
assert_eq!(from_markdown(&md).unwrap(), before_slot);
}
#[test]
fn known_image_alt_limit() {
let rt = with_islands(
&ISLAND_SLOT.to_string(),
vec![
Island::new("isl-0".into(), IslandType::Image)
.with_props(serde_json::json!({"alt": " a ", "url": "u"})),
],
);
let back = from_markdown(&to_markdown(&rt)).unwrap();
assert_eq!(
back.islands[0].props["alt"], "a",
"if this ever round-trips, promote it out of the known-limits list"
);
}
#[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().into_content();
rt.marks.push(Mark {
start: 8,
end: 14,
kind: MarkKind::Anchor { id: "c1".into() },
});
let rt = rt.into_normalized();
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 code_span_edges_keep_their_pad() {
for (md, text, want) in [
("`` `a ``", "`a", "`` `a ``"),
("`` a` ``", "a`", "`` a` ``"),
("`` `a` ``", "`a`", "`` `a` ``"),
("`` ` ``", "`", "`` ` ``"),
("` a `", " a ", "` a `"),
("`a`", "a", "`a`"),
("` `", " ", "` `"),
] {
let rt = from_markdown(md).unwrap();
assert_eq!(rt.text, text, "import of {md:?}");
assert_eq!(to_markdown(&rt), want);
round_trips(md);
}
}
#[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 url_specials_round_trip() {
round_trips("a [t](<foo bar>) b");
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 a_url_carrying_a_line_ending_still_projects_as_a_link() {
let rt = Content::new("t x".to_string(), vec![Line::new(LineKind::Para)])
.with_marks(vec![Mark::new(
0,
1,
MarkKind::Link {
url: "a\nb".into(),
},
)])
.into_normalized();
let back = from_markdown(&to_markdown(&rt)).expect("re-imports");
assert_eq!(back.text, "t x", "the display text did not leak");
assert_eq!(
back.marks.first().map(|m| &m.kind),
Some(&MarkKind::Link {
url: "a%0Ab".into()
})
);
let isl = crate::model::Island::new("i1".into(), IslandType::Image)
.with_props(serde_json::json!({"alt": "a", "url": "u\rv"}));
let rt = Content::new(format!("x{ISLAND_SLOT}"), vec![Line::new(LineKind::Para)])
.with_islands(vec![isl])
.into_normalized();
let back = from_markdown(&to_markdown(&rt)).expect("re-imports");
assert_eq!(back.islands.len(), 1, "the island survived");
assert_eq!(back.islands[0].props["url"], "u%0Dv");
}
#[test]
fn net_drops_only_the_leaking_marks() {
for (label, text, spans, want) in [
("leaking alone", "a.b", vec![(0, 2)], "a.b"),
("representable alone", "ab cd", vec![(0, 2)], "**ab** cd"),
(
"leaking then representable",
"a.b and cd ef",
vec![(1, 3), (8, 10)],
"a.b and **cd** ef",
),
(
"representable then leaking",
"cd ef and a.b",
vec![(0, 2), (11, 13)],
"**cd** ef and a.b",
),
] {
let rt = marked(
text,
spans
.into_iter()
.map(|(start, end)| Mark {
start,
end,
kind: MarkKind::Strong,
})
.collect(),
);
let md = to_markdown(&rt);
assert_eq!(md, want, "{label}");
assert_eq!(from_markdown(&md).unwrap().text, text, "{label}: text drift");
}
}
#[test]
fn a_mark_is_respelled_before_it_is_dropped() {
for (label, src) in [
("strong ending in `*`, then emph", "__a**__*b*"),
("the same over non-ASCII", "__౸**__*0_*———"),
("emph and strike over one span", "౸~~*¡± ±*~~"),
("bold, emph, bold", "**a±**_b_**c**"),
("the same over a literal `*`", "__*__*౸*__a**0__"),
] {
let once = from_markdown(src).unwrap();
assert!(once.marks.len() >= 2, "{label}: nothing to lose");
let md = to_markdown(&once);
let twice = from_markdown(&md).unwrap();
assert_eq!(&twice.text, &once.text, "{label}: text drift. md: {md:?}");
assert_eq!(&twice.marks, &once.marks, "{label}: mark lost. md: {md:?}");
}
}
#[test]
fn net_resolves_a_line_at_the_budget_guarantee() {
let (mut text, mut marks) = (String::new(), Vec::new());
for i in 0..32 {
let at = i * 6;
if i % 2 == 0 {
text.push_str("abcde "); marks.push((at, at + 5));
} else {
text.push_str("abc.e "); marks.push((at + 3, at + 5));
}
}
let text = text.trim_end();
let rt = marked(
text,
marks
.into_iter()
.map(|(start, end)| Mark {
start,
end,
kind: MarkKind::Strong,
})
.collect(),
);
let md = to_markdown(&rt);
assert_eq!(md.matches("**abcde**").count(), 16, "every good mark kept");
assert_eq!(md.matches("**").count(), 32, "every leaking mark dropped");
assert_eq!(from_markdown(&md).unwrap().text, text);
}
#[test]
fn net_bounds_a_pathological_line() {
let text = "a.b, ".repeat(200);
let text = text.trim_end();
let rt = marked(
text,
(0..200)
.map(|i| Mark {
start: i * 5 + 1,
end: i * 5 + 4,
kind: MarkKind::Strong,
})
.collect(),
);
let md = to_markdown(&rt);
assert!(!md.contains('*'), "every leaking mark dropped: {md:?}");
assert_eq!(from_markdown(&md).unwrap().text, text);
}
#[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:?}"
);
}
}