use crate::model::{
Container, Island, Line, LineKind, Loss, Mark, MarkKind, Content, ISLAND_SLOT,
};
use crate::island::KnownIslandType;
use crate::normalize::normalize_markdown;
use crate::MAX_NESTING_DEPTH;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use serde_json::json;
use std::cell::RefCell;
use std::collections::HashSet;
use std::ops::Range;
use std::rc::Rc;
type UnderlineOpens = Rc<RefCell<HashSet<usize>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportError {
NestingTooDeep { depth: usize, max: usize },
}
impl std::fmt::Display for ImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImportError::NestingTooDeep { depth, max } => {
write!(f, "nesting too deep: {depth} (max {max})")
}
}
}
}
impl std::error::Error for ImportError {}
pub fn from_markdown(markdown: &str) -> Result<Content, ImportError> {
let normalized = normalize_markdown(markdown);
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(&normalized, options);
let underline_opens: UnderlineOpens = Rc::new(RefCell::new(HashSet::new()));
let fixer = MarkdownFixer::new(
parser.into_offset_iter(),
&normalized,
Rc::clone(&underline_opens),
);
let mut b = Builder::new(underline_opens);
b.run(fixer)?;
let mut rt = b.finish();
rt.normalize();
Ok(rt)
}
pub fn from_plaintext(s: &str) -> Content {
let text: String = s
.chars()
.filter(|&c| c != '\r' && c != ISLAND_SLOT && !crate::normalize::is_bidi_char(c))
.collect();
let mut prev_nonempty = false;
let lines = text
.split('\n')
.map(|seg| {
let continues = prev_nonempty && !seg.is_empty();
prev_nonempty = !seg.is_empty();
Line {
kind: LineKind::Para,
containers: Vec::new(),
continues,
}
})
.collect();
Content {
text,
lines,
marks: Vec::new(),
islands: Vec::new(),
}
}
#[derive(Default)]
struct Inline {
text: String,
pos: usize,
marks: Vec<Mark>,
open: Vec<(MarkKind, usize)>,
}
impl Inline {
fn push_text(&mut self, s: &str) {
for c in s.chars() {
let c = match c {
'\r' => continue,
ISLAND_SLOT => continue,
'\n' => ' ',
other => other,
};
self.text.push(c);
self.pos += 1;
}
}
fn push_raw(&mut self, c: char) {
self.text.push(c);
self.pos += 1;
}
fn open_mark(&mut self, kind: MarkKind) {
self.open.push((kind, self.pos));
}
fn close_mark(&mut self) {
if let Some((kind, start)) = self.open.pop() {
self.marks.push(Mark {
start,
end: self.pos,
kind,
});
}
}
fn push_code(&mut self, s: &str) {
let start = self.pos;
self.push_text(s);
self.marks.push(Mark {
start,
end: self.pos,
kind: MarkKind::Code,
});
}
}
struct Builder {
underline_opens: UnderlineOpens,
inline: Inline,
lines: Vec<Line>,
cur: Option<Line>, pending: Option<(LineKind, bool)>,
islands: Vec<Island>,
island_seq: usize,
containers: Vec<Container>,
container_marks: Vec<usize>,
list_stack: Vec<ListInfo>,
code_lang: Option<String>,
in_code: bool,
code_opened: bool, image_depth: usize,
image_url: String,
image_alt: String,
table: Option<TableAcc>,
}
#[derive(Clone)]
struct ListInfo {
ordered: bool,
start: u64,
count: u64,
}
struct TableAcc {
aligns: Vec<&'static str>,
header: Vec<serde_json::Value>,
rows: Vec<Vec<serde_json::Value>>,
cur_row: Vec<serde_json::Value>,
in_head: bool,
cell: Option<Inline>,
img_depth: usize,
degraded: bool,
}
fn align_str(a: &pulldown_cmark::Alignment) -> &'static str {
match a {
pulldown_cmark::Alignment::None => "none",
pulldown_cmark::Alignment::Left => "left",
pulldown_cmark::Alignment::Center => "center",
pulldown_cmark::Alignment::Right => "right",
}
}
impl Builder {
fn new(underline_opens: UnderlineOpens) -> Self {
Builder {
underline_opens,
inline: Inline::default(),
lines: Vec::new(),
cur: None,
pending: None,
islands: Vec::new(),
island_seq: 0,
containers: Vec::new(),
container_marks: Vec::new(),
list_stack: Vec::new(),
code_lang: None,
in_code: false,
code_opened: false,
image_depth: 0,
image_url: String::new(),
image_alt: String::new(),
table: None,
}
}
fn open_line(&mut self, kind: LineKind, continues: bool) {
let continues = continues && self.cur.is_some();
if let Some(prev) = self.cur.take() {
self.inline.push_raw('\n');
self.lines.push(prev);
}
self.cur = Some(Line {
kind,
containers: self.containers.clone(),
continues,
});
}
fn ensure_open(&mut self, default: LineKind) {
if let Some((k, cont)) = self.pending.take() {
self.open_line(k, cont);
} else if self.cur.is_none() {
self.open_line(default, false);
}
}
fn push_inline(&mut self, s: &str) {
self.ensure_open(LineKind::Para);
self.inline.push_text(s);
}
fn emitted(&self) -> usize {
self.lines.len() + usize::from(self.cur.is_some())
}
fn flush_empty_block(&mut self) {
if let Some((k, cont)) = self.pending.take() {
self.open_line(k, cont);
}
}
fn close_container(&mut self, mark: usize) {
if self.emitted() == mark {
self.pending = None;
self.open_line(LineKind::Para, false);
}
self.containers.pop();
}
fn open_mark(&mut self, kind: MarkKind) {
self.ensure_open(LineKind::Para);
self.inline.open_mark(kind);
}
fn close_mark(&mut self) {
self.inline.close_mark();
}
fn mint_island(&mut self, kind: KnownIslandType, props: serde_json::Value, loss: Loss) {
let id = format!("isl-{}", self.island_seq);
self.island_seq += 1;
self.islands.push(Island {
id,
island_type: kind.as_str().to_string(),
props,
loss,
});
}
fn check_depth(&self) -> Result<(), ImportError> {
let depth = self.containers.len() + self.inline.open.len();
if depth > MAX_NESTING_DEPTH {
return Err(ImportError::NestingTooDeep {
depth,
max: MAX_NESTING_DEPTH,
});
}
Ok(())
}
fn strong_kind(&self, start: usize) -> MarkKind {
if self.underline_opens.borrow().contains(&start) {
MarkKind::Underline
} else {
MarkKind::Strong
}
}
fn run<'a, I>(&mut self, iter: I) -> Result<(), ImportError>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
for (event, range) in iter {
if self.image_depth > 0 {
match &event {
Event::Start(Tag::Image { .. }) => self.image_depth += 1,
Event::End(TagEnd::Image) => {
self.image_depth -= 1;
if self.image_depth == 0 {
self.emit_image();
}
}
Event::Text(t) | Event::Code(t) => self.image_alt.push_str(t),
Event::SoftBreak | Event::HardBreak => self.image_alt.push(' '),
_ => {}
}
continue;
}
if self.table.is_some() {
self.table_event(&event, &range);
if matches!(event, Event::End(TagEnd::Table)) {
self.emit_table();
}
continue;
}
match event {
Event::Start(tag) => self.start_tag(tag, range)?,
Event::End(tag) => self.end_tag(tag),
Event::Text(t) => {
if self.in_code {
self.push_code_content(&t);
} else {
self.push_inline(&t);
}
}
Event::Code(t) => {
self.ensure_open(LineKind::Para);
self.inline.push_code(&t);
}
Event::Rule => self.open_line(LineKind::Rule, false),
Event::SoftBreak => self.push_inline(" "),
Event::HardBreak => {
match self.cur.as_ref().map(|l| &l.kind) {
Some(LineKind::Heading { .. }) => self.push_inline(" "),
_ => {
let kind = self
.cur
.as_ref()
.map(|l| l.kind.clone())
.unwrap_or(LineKind::Para);
self.pending = Some((kind, true));
}
}
}
_ => {}
}
}
Ok(())
}
fn start_tag<'a>(&mut self, tag: Tag<'a>, range: Range<usize>) -> Result<(), ImportError> {
match tag {
Tag::Paragraph => self.pending = Some((LineKind::Para, false)),
Tag::Heading { level, .. } => {
self.pending = Some((
LineKind::Heading {
level: heading_level(level),
},
false,
))
}
Tag::CodeBlock(kind) => {
self.pending = None; self.in_code = true;
self.code_lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
let l = sanitize_lang(&lang);
if l.is_empty() {
None
} else {
Some(l)
}
}
pulldown_cmark::CodeBlockKind::Indented => None,
};
self.code_opened = false;
}
Tag::List(start) => {
self.pending = None; self.list_stack.push(ListInfo {
ordered: start.is_some(),
start: start.unwrap_or(1),
count: 0,
});
}
Tag::Item => {
self.pending = Some((LineKind::Para, false));
self.container_marks.push(self.emitted());
let container = match self.list_stack.last_mut() {
Some(info) => {
let ordinal = info.count;
info.count += 1;
Container::ListItem {
ordered: info.ordered,
start: info.start,
ordinal,
}
}
None => Container::ListItem {
ordered: false,
start: 1,
ordinal: 0,
},
};
self.containers.push(container);
self.check_depth()?;
}
Tag::BlockQuote(_) => {
self.pending = None; self.container_marks.push(self.emitted());
self.containers.push(Container::Quote);
self.check_depth()?;
}
Tag::Table(aligns) => {
self.pending = None;
self.open_line(LineKind::Island, false);
self.inline.push_raw(ISLAND_SLOT);
self.table = Some(TableAcc {
aligns: aligns.iter().map(align_str).collect(),
header: Vec::new(),
rows: Vec::new(),
cur_row: Vec::new(),
in_head: false,
cell: None,
img_depth: 0,
degraded: false,
});
}
Tag::Emphasis => {
self.open_mark(MarkKind::Emph);
self.check_depth()?;
}
Tag::Strong => {
let kind = self.strong_kind(range.start);
self.open_mark(kind);
self.check_depth()?;
}
Tag::Strikethrough => {
self.open_mark(MarkKind::Strike);
self.check_depth()?;
}
Tag::Link { dest_url, .. } => {
self.open_mark(MarkKind::Link {
url: dest_url.to_string(),
});
self.check_depth()?;
}
Tag::Image { dest_url, .. } => {
self.image_url = dest_url.to_string();
self.image_alt.clear();
self.image_depth = 1;
}
_ => {}
}
Ok(())
}
fn end_tag(&mut self, tag: TagEnd) {
match tag {
TagEnd::CodeBlock => {
if !self.code_opened {
let lang = self.code_lang.take();
self.open_line(LineKind::Code { lang }, false);
}
self.in_code = false;
self.code_lang = None;
}
TagEnd::List(_) => {
self.list_stack.pop();
}
TagEnd::Item => {
let mark = self.container_marks.pop().unwrap_or(0);
self.close_container(mark);
}
TagEnd::BlockQuote(_) => {
let mark = self.container_marks.pop().unwrap_or(0);
self.close_container(mark);
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
self.close_mark()
}
TagEnd::Heading(_) | TagEnd::Paragraph => self.flush_empty_block(),
_ => {}
}
}
fn push_code_content(&mut self, content: &str) {
let content = content.strip_suffix('\n').unwrap_or(content);
for seg in content.split('\n') {
let continues = self.code_opened;
self.open_line(
LineKind::Code {
lang: self.code_lang.clone(),
},
continues,
);
self.code_opened = true;
self.push_code_line(seg);
}
}
fn push_code_line(&mut self, seg: &str) {
for c in seg.chars() {
match c {
'\r' | '\n' => continue,
ISLAND_SLOT => continue,
other => self.inline.push_raw(other),
}
}
}
fn cell_mut(&mut self) -> Option<&mut Inline> {
self.table.as_mut()?.cell.as_mut()
}
fn table_event(&mut self, event: &Event, range: &Range<usize>) {
if self.table.as_ref().is_some_and(|a| a.img_depth > 0) {
match event {
Event::Start(Tag::Image { .. }) => {
if let Some(a) = self.table.as_mut() {
a.img_depth += 1;
}
}
Event::End(TagEnd::Image) => {
if let Some(a) = self.table.as_mut() {
a.img_depth -= 1;
}
}
Event::Text(t) | Event::Code(t) => {
if let Some(c) = self.cell_mut() {
c.push_text(t);
}
}
Event::SoftBreak | Event::HardBreak => {
if let Some(c) = self.cell_mut() {
c.push_text(" ");
}
}
_ => {}
}
return;
}
match event {
Event::Start(Tag::Image { .. }) => {
if let Some(a) = self.table.as_mut() {
a.img_depth += 1;
a.degraded = true;
}
}
Event::Start(Tag::TableHead) => {
if let Some(a) = self.table.as_mut() {
a.in_head = true;
}
}
Event::End(TagEnd::TableHead) => {
if let Some(a) = self.table.as_mut() {
a.header = std::mem::take(&mut a.cur_row);
a.in_head = false;
}
}
Event::Start(Tag::TableRow) => {
if let Some(a) = self.table.as_mut() {
a.cur_row.clear();
}
}
Event::End(TagEnd::TableRow) => {
if let Some(a) = self.table.as_mut() {
if !a.in_head {
let row = std::mem::take(&mut a.cur_row);
a.rows.push(row);
}
}
}
Event::Start(Tag::TableCell) => {
if let Some(a) = self.table.as_mut() {
a.cell = Some(Inline::default());
}
}
Event::End(TagEnd::TableCell) => {
if let Some(a) = self.table.as_mut() {
if let Some(mut cell) = a.cell.take() {
while !cell.open.is_empty() {
cell.close_mark();
}
a.cur_row
.push(crate::serial::cell_to_value(&cell.text, &cell.marks));
}
}
}
Event::Text(t) => {
if let Some(c) = self.cell_mut() {
c.push_text(t);
}
}
Event::Code(t) => {
if let Some(c) = self.cell_mut() {
c.push_code(t);
}
}
Event::SoftBreak | Event::HardBreak => {
if let Some(c) = self.cell_mut() {
c.push_text(" ");
}
}
Event::Start(Tag::Emphasis) => {
if let Some(c) = self.cell_mut() {
c.open_mark(MarkKind::Emph);
}
}
Event::Start(Tag::Strong) => {
let kind = self.strong_kind(range.start);
if let Some(c) = self.cell_mut() {
c.open_mark(kind);
}
}
Event::Start(Tag::Strikethrough) => {
if let Some(c) = self.cell_mut() {
c.open_mark(MarkKind::Strike);
}
}
Event::Start(Tag::Link { dest_url, .. }) => {
let url = dest_url.to_string();
if let Some(c) = self.cell_mut() {
c.open_mark(MarkKind::Link { url });
}
}
Event::End(TagEnd::Emphasis)
| Event::End(TagEnd::Strong)
| Event::End(TagEnd::Strikethrough)
| Event::End(TagEnd::Link) => {
if let Some(c) = self.cell_mut() {
c.close_mark();
}
}
_ => {}
}
}
fn emit_table(&mut self) {
if let Some(acc) = self.table.take() {
let props = json!({
"aligns": acc.aligns,
"header": acc.header,
"rows": acc.rows,
});
let loss = if acc.degraded {
Loss::Degraded
} else {
KnownIslandType::Table.default_loss()
};
self.mint_island(KnownIslandType::Table, props, loss);
}
}
fn emit_image(&mut self) {
self.ensure_open(LineKind::Para);
self.inline.push_raw(ISLAND_SLOT);
let props = json!({
"url": self.image_url,
"alt": self.image_alt.trim(),
});
self.mint_island(KnownIslandType::Image, props, KnownIslandType::Image.default_loss());
}
fn finish(mut self) -> Content {
if let Some(last) = self.cur.take() {
self.lines.push(last);
}
if self.lines.is_empty() {
self.lines.push(Line {
kind: LineKind::Para,
containers: Vec::new(),
continues: false,
});
}
while !self.inline.open.is_empty() {
self.close_mark();
}
Content {
text: self.inline.text,
lines: self.lines,
marks: self.inline.marks,
islands: self.islands,
}
}
}
fn heading_level(level: pulldown_cmark::HeadingLevel) -> u8 {
use pulldown_cmark::HeadingLevel::*;
match level {
H1 => 1,
H2 => 2,
H3 => 3,
H4 => 4,
H5 => 5,
H6 => 6,
}
}
fn sanitize_lang(lang: &str) -> String {
lang.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
.collect()
}
fn is_u_open_tag(html: &str) -> bool {
let s = html.trim();
if s.starts_with('<') && s.ends_with('>') {
s[1..s.len() - 1].trim().eq_ignore_ascii_case("u")
} else {
false
}
}
fn is_u_close_tag(html: &str) -> bool {
let s = html.trim();
if s.starts_with("</") && s.ends_with('>') {
s[2..s.len() - 1].trim().eq_ignore_ascii_case("u")
} else {
false
}
}
struct MarkdownFixer<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>> {
inner: std::iter::Peekable<I>,
source: &'a str,
underline_opens: UnderlineOpens,
buffer: Vec<(Event<'a>, Range<usize>)>,
emph_depth: usize,
strong_depth: usize,
}
impl<'a, I> MarkdownFixer<'a, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
fn new(inner: I, source: &'a str, underline_opens: UnderlineOpens) -> Self {
Self {
inner: inner.peekable(),
source,
underline_opens,
buffer: Vec::new(),
emph_depth: 0,
strong_depth: 0,
}
}
fn events_for_stars(
star_count: usize,
is_start: bool,
start_idx: usize,
) -> Vec<(Event<'a>, Range<usize>)> {
let mut events = Vec::new();
let mut offset = 0;
let mut remaining = star_count;
if remaining >= 2 {
let len = 2;
let range = start_idx + offset..start_idx + offset + len;
let event = if is_start {
Event::Start(Tag::Strong)
} else {
Event::End(TagEnd::Strong)
};
events.push((event, range));
remaining -= 2;
offset += 2;
}
if remaining >= 1 {
let len = 1;
let range = start_idx + offset..start_idx + offset + len;
let event = if is_start {
Event::Start(Tag::Emphasis)
} else {
Event::End(TagEnd::Emphasis)
};
events.push((event, range));
}
if !is_start {
events.reverse();
}
events
}
fn coalesce_text_range(&mut self, initial_range: Range<usize>) -> Range<usize> {
let mut merged_range = initial_range;
while let Some((next_event, next_range)) = self.inner.peek() {
if matches!(next_event, Event::Text(_)) && next_range.start == merged_range.end {
merged_range.end = next_range.end;
self.inner.next();
} else {
break;
}
}
merged_range
}
fn closable_star_count(&self, star_count: usize) -> usize {
let mut remaining = star_count;
let mut consumed = 0;
if remaining >= 2 && self.strong_depth > 0 {
remaining -= 2;
consumed += 2;
}
if remaining >= 1 && self.emph_depth > 0 {
consumed += 1;
}
consumed
}
fn handle_candidate(
&mut self,
candidate: (Event<'a>, Range<usize>),
) -> Option<(Event<'a>, Range<usize>)> {
let (event, range) = candidate;
match &event {
Event::Start(Tag::Emphasis) => self.emph_depth += 1,
Event::Start(Tag::Strong) => self.strong_depth += 1,
Event::End(TagEnd::Emphasis) => self.emph_depth = self.emph_depth.saturating_sub(1),
Event::End(TagEnd::Strong) => self.strong_depth = self.strong_depth.saturating_sub(1),
_ => {}
}
match &event {
Event::Text(cow_str) => {
let s = cow_str.as_ref();
if s.ends_with('*') {
let is_strong_start = if let Some(next) = self.buffer.last() {
matches!(next.0, Event::Start(Tag::Strong))
} else {
matches!(self.inner.peek(), Some((Event::Start(Tag::Strong), _)))
};
if is_strong_start {
let star_count = s.chars().rev().take_while(|c| *c == '*').count();
if star_count > 0 && star_count <= 3 {
let text_len = s.len() - star_count;
let text_content = &s[..text_len];
let star_events =
Self::events_for_stars(star_count, true, range.start + text_len);
let next_event = if !self.buffer.is_empty() {
self.buffer.pop().unwrap()
} else {
self.inner.next().unwrap()
};
self.buffer.push(next_event);
for ev in star_events.into_iter().rev() {
self.buffer.push(ev);
}
if !text_content.is_empty() {
return Some((
Event::Text(text_content.to_string().into()),
range.start..range.start + text_len,
));
} else {
return None;
}
}
}
}
}
Event::End(TagEnd::Strong) | Event::End(TagEnd::Emphasis) => {
let has_open_tags = self.emph_depth > 0 || self.strong_depth > 0;
if !has_open_tags {
return Some((event, range));
}
let next_is_star_text = if let Some((Event::Text(cow_str), _)) = self.buffer.last()
{
cow_str.starts_with('*')
} else if let Some((Event::Text(cow_str), _)) = self.inner.peek() {
cow_str.starts_with('*')
} else {
false
};
if next_is_star_text {
let (text_event, text_range) = if !self.buffer.is_empty() {
self.buffer.pop().unwrap()
} else {
let (_ev, rng) = self.inner.next().unwrap();
let merged_range = self.coalesce_text_range(rng);
let text = self.source[merged_range.clone()].into();
(Event::Text(text), merged_range)
};
if let Event::Text(cow_str) = text_event {
let s = cow_str.as_ref();
let star_count = s.chars().take_while(|c| *c == '*').count();
let consumable = self.closable_star_count(star_count);
if consumable > 0 {
let star_events =
Self::events_for_stars(consumable, false, text_range.start);
let text_after = &s[consumable..];
if !text_after.is_empty() {
self.buffer.push((
Event::Text(text_after.to_string().into()),
text_range.start + consumable..text_range.end,
));
}
for ev in star_events.into_iter().rev() {
self.buffer.push(ev);
}
return Some((event, range));
} else {
self.buffer.push((Event::Text(cow_str), text_range));
}
}
}
}
_ => {}
}
Some((event, range))
}
}
impl<'a, I> Iterator for MarkdownFixer<'a, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
type Item = (Event<'a>, Range<usize>);
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(event) = self.buffer.pop() {
if let Some(result) = self.handle_candidate(event) {
return Some(result);
} else {
continue;
}
}
let (event, range) = self.inner.next()?;
let (event, range) = match event {
Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_open_tag(html) => {
self.underline_opens.borrow_mut().insert(range.start);
(Event::Start(Tag::Strong), range)
}
Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_close_tag(html) => {
(Event::End(TagEnd::Strong), range)
}
Event::Html(_) | Event::InlineHtml(_) => continue,
other => (other, range),
};
if let Some(result) = self.handle_candidate((event, range)) {
return Some(result);
} else {
continue;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::LineKind;
fn imp(md: &str) -> Content {
let rt = from_markdown(md).unwrap();
assert_eq!(rt.validate(), Ok(()), "invariants for {md:?}");
rt
}
fn imp_plain(s: &str) -> Content {
let rt = from_plaintext(s);
assert_eq!(rt.validate(), Ok(()), "invariants for {s:?}");
rt
}
#[test]
fn plaintext_is_literal_and_plain() {
let rt = imp_plain("a *star* and _under_ #hash");
assert_eq!(rt.text, "a *star* and _under_ #hash");
assert!(rt.marks.is_empty());
assert!(rt.islands.is_empty());
assert!(rt.is_plain());
assert!(rt.is_inline(), "one line with no formatting is also inline");
}
#[test]
fn plaintext_round_trip_is_verbatim_and_idempotent() {
for s in ["", "one line", "a\nb", "a\n\nb", "trailing\n", "*not bold*"] {
let rt = imp_plain(s);
assert_eq!(crate::export::to_plaintext(&rt), s, "verbatim for {s:?}");
let rt2 = from_plaintext(&crate::export::to_plaintext(&rt));
assert_eq!(rt2.text, rt.text, "idempotent for {s:?}");
assert_eq!(rt2.lines, rt.lines, "idempotent structure for {s:?}");
}
}
#[test]
fn plaintext_derives_continues_from_line_structure() {
let rt = imp_plain("a\nb");
assert_eq!(rt.lines.len(), 2);
assert!(!rt.lines[0].continues);
assert!(rt.lines[1].continues, "lone \\n is a within-paragraph break");
let rt = imp_plain("a\n\nb");
assert_eq!(rt.lines.len(), 3);
assert!(!rt.lines[0].continues);
assert!(!rt.lines[1].continues, "the blank line is a paragraph boundary");
assert!(!rt.lines[2].continues, "text after a blank line starts a new block");
}
#[test]
fn plaintext_strips_invariant_breakers() {
let rt = imp_plain("a\r\nb");
assert_eq!(rt.text, "a\nb", "CRLF collapses to LF");
let rt = imp_plain(&format!("a{ISLAND_SLOT}b"));
assert_eq!(rt.text, "ab", "the reserved island slot is dropped");
assert_eq!(rt.islands.len(), 0);
}
#[test]
fn plain_paragraph() {
let rt = imp("Hello world");
assert_eq!(rt.text, "Hello world");
assert_eq!(rt.lines.len(), 1);
assert_eq!(rt.lines[0].kind, LineKind::Para);
assert!(rt.marks.is_empty());
}
#[test]
fn bold_and_italic_marks() {
let rt = imp("a **b** _c_");
assert_eq!(rt.text, "a b c");
assert!(rt.marks.contains(&Mark {
start: 2,
end: 3,
kind: MarkKind::Strong
}));
assert!(rt.marks.contains(&Mark {
start: 4,
end: 5,
kind: MarkKind::Emph
}));
}
#[test]
fn underline_from_u_tag() {
let rt = imp("x <u>y</u> z");
assert_eq!(rt.text, "x y z");
assert!(rt
.marks
.iter()
.any(|m| m.kind == MarkKind::Underline && m.start == 2 && m.end == 3));
}
#[test]
fn other_html_stripped() {
let rt = imp("a <span>b</span> c");
assert_eq!(rt.text, "a b c");
}
#[test]
fn ul_lookalike_is_not_underline() {
let rt = imp("x <ul>y</ul> z");
assert_eq!(rt.text, "x y z");
assert!(rt
.marks
.iter()
.all(|m| m.kind != MarkKind::Underline && m.kind != MarkKind::Strong));
}
#[test]
fn two_paragraphs_two_lines() {
let rt = imp("one\n\ntwo");
assert_eq!(rt.text, "one\ntwo");
assert_eq!(rt.lines.len(), 2);
assert!(rt.lines.iter().all(|l| l.kind == LineKind::Para));
}
#[test]
fn heading_line_kind() {
let rt = imp("## Title");
assert_eq!(rt.text, "Title");
assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
}
#[test]
fn inline_code_mark() {
let rt = imp("run `cargo test` now");
assert_eq!(rt.text, "run cargo test now");
assert!(rt
.marks
.iter()
.any(|m| m.kind == MarkKind::Code && m.start == 4 && m.end == 14));
}
#[test]
fn code_block_lines() {
let rt = imp("```rust\nfn a() {}\nfn b() {}\n```");
assert_eq!(rt.text, "fn a() {}\nfn b() {}");
assert_eq!(rt.lines.len(), 2);
assert!(rt.lines.iter().all(|l| l.kind
== LineKind::Code {
lang: Some("rust".into())
}));
}
#[test]
fn bullet_list_containers() {
let rt = imp("- a\n- b");
assert_eq!(rt.text, "a\nb");
assert_eq!(rt.lines.len(), 2);
assert_eq!(
rt.lines[0].containers,
vec![Container::ListItem {
ordered: false,
start: 1,
ordinal: 0
}]
);
assert_eq!(
rt.lines[1].containers,
vec![Container::ListItem {
ordered: false,
start: 1,
ordinal: 1
}]
);
}
#[test]
fn ordered_list_custom_start() {
let rt = imp("3. a\n4. b");
assert_eq!(
rt.lines[0].containers,
vec![Container::ListItem {
ordered: true,
start: 3,
ordinal: 0
}]
);
assert_eq!(
rt.lines[1].containers,
vec![Container::ListItem {
ordered: true,
start: 3,
ordinal: 1
}]
);
}
#[test]
fn multi_paragraph_list_item_shares_container() {
let rt = imp("- first\n\n second");
assert_eq!(rt.lines.len(), 2);
assert_eq!(rt.lines[0].containers, rt.lines[1].containers);
assert_eq!(
rt.lines[0].containers,
vec![Container::ListItem {
ordered: false,
start: 1,
ordinal: 0
}]
);
}
#[test]
fn blockquote_container() {
let rt = imp("> quoted");
assert_eq!(rt.text, "quoted");
assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
}
#[test]
fn thematic_break_is_rule_line() {
for src in ["---", "***", "___"] {
let md = format!("one\n\n{src}\n\ntwo");
let rt = imp(&md);
assert_eq!(rt.lines.len(), 3, "source: {src}");
assert_eq!(rt.lines[0].kind, LineKind::Para);
assert_eq!(rt.lines[1].kind, LineKind::Rule, "source: {src}");
assert_eq!(rt.lines[2].kind, LineKind::Para);
assert_eq!(rt.text, "one\n\ntwo");
}
}
#[test]
fn table_is_block_island() {
let rt = imp("| a | b |\n|---|---|\n| 1 | 2 |");
assert_eq!(rt.text, "\u{FFFC}");
assert_eq!(rt.lines[0].kind, LineKind::Island);
assert_eq!(rt.islands.len(), 1);
assert_eq!(rt.islands[0].island_type, "table");
assert_eq!(rt.islands[0].loss, Loss::Lossless);
}
#[test]
fn island_ids_are_deterministic_and_positional() {
let md = "\n\n| h |\n|---|\n| c |";
let a = imp(md);
let b = imp(md);
assert_eq!(a.to_canonical_json(), b.to_canonical_json());
let ids: Vec<&str> = a.islands.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, ["isl-0", "isl-1"]);
}
#[test]
fn table_with_cell_image_degrades() {
let rt = imp("| a | b |\n|---|---|\n|  | 2 |");
assert_eq!(rt.islands.len(), 1);
assert_eq!(rt.islands[0].island_type, "table");
assert_eq!(rt.islands[0].loss, Loss::Degraded);
assert_eq!(rt.islands[0].props["rows"][0][0]["text"], "a cat");
let plain = imp("| a | b |\n|---|---|\n| 1 | 2 |");
assert_eq!(plain.islands[0].loss, Loss::Lossless);
}
#[test]
fn image_is_inline_island() {
let rt = imp("see  here");
assert_eq!(rt.text, "see \u{FFFC} here");
assert_eq!(rt.islands.len(), 1);
assert_eq!(rt.islands[0].island_type, "image");
assert_eq!(rt.islands[0].props["url"], "cat.png");
assert_eq!(rt.islands[0].props["alt"], "a cat");
}
#[test]
fn empty_list_item_keeps_its_line() {
let rt = imp("- a\n-\n- b");
assert_eq!(rt.lines.len(), 3, "empty middle item preserved");
}
#[test]
fn empty_blockquote_keeps_its_line() {
let rt = imp("> ");
assert_eq!(rt.lines.len(), 1);
assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
}
#[test]
fn adjacent_sibling_lists_merge_is_stable() {
let rt = imp("* a\n\n+ b");
let rt2 = from_markdown(&crate::export::to_markdown(&rt)).unwrap();
assert_eq!(rt, rt2, "merged sibling lists still round-trip");
}
#[test]
fn empty_input_one_empty_line() {
let rt = imp("");
assert_eq!(rt.text, "");
assert_eq!(rt.lines.len(), 1);
}
#[test]
fn mark_does_not_swallow_leading_newline() {
let rt = imp("a\n\n**b**");
assert_eq!(rt.text, "a\nb");
let m = &rt.marks[0];
assert_eq!((m.start, m.end), (2, 3));
assert_eq!(rt.text.chars().nth(m.start), Some('b'));
}
#[test]
fn import_and_editor_content_same_canonical_bytes() {
let imported = imp("a\n\n**b**");
let editor = Content {
text: "a\nb".into(),
lines: vec![
Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
},
Line {
kind: LineKind::Para,
containers: vec![],
continues: false,
},
],
marks: vec![Mark {
start: 2,
end: 3,
kind: MarkKind::Strong,
}],
islands: vec![],
};
assert_eq!(imported.to_canonical_json(), editor.to_canonical_json());
}
#[test]
fn hard_break_is_a_continuation_line() {
let rt = imp("line one\\\nline two");
assert_eq!(rt.text, "line one\nline two");
assert_eq!(rt.lines.len(), 2);
assert!(!rt.lines[0].continues);
assert!(rt.lines[1].continues, "hard break -> continuation line");
}
#[test]
fn heading_cannot_carry_hard_break() {
let rt = imp("## a \nb");
assert_eq!(rt.text, "a\nb");
assert_eq!(rt.lines.len(), 2);
assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
assert_eq!(rt.lines[1].kind, LineKind::Para);
assert!(!rt.lines[1].continues, "separate block, not a continuation");
}
#[test]
fn astral_positions_are_usv() {
let rt = imp("a😀**b**");
assert_eq!(rt.text, "a😀b");
assert!(rt
.marks
.iter()
.any(|m| m.start == 2 && m.end == 3 && m.kind == MarkKind::Strong));
}
}