Files
craft-libs/crates/markdown/src/tests.rs
T
Dylan M. Taylor 1e3d3968c1 Add craft-markdown: Markdown read and written, on craft-flow (stacked on #1025) (#1030)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* Add craft-flow: the flow model the text formats map to, on craft-wordmodel

WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow,
FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and
from_doc, picture sniffing, colours, base64 and the Dropped report. Only
the paths changed (wordcraft_doc is craft_wordmodel); the report helpers
are public so the format crates can call them; the image re-encoding
helper stays with WordCraft's RTF writer.

* Add craft-markdown: Markdown read and written, on craft-flow

WordCraft's crates/formats/src/markdown (07f97737) with its 35 tests,
moved onto craft-flow and craft-wordmodel. Character references come from
craft-html. The table cell budget lives in craft-flow, which names it in
its warning. Two test lines that wrote plain text through WordCraft's txt
module stay in WordCraft.
2026-11-11 20:46:15 -05:00

796 lines
37 KiB
Rust

use super::*;
fn inl(s: &str) -> Vec<Inline> {
parse_inlines(s, &Fmt::default(), 0, &Refs::new())
}
fn spans(s: &str) -> Vec<(String, bool, bool)> {
inl(s).into_iter().filter_map(|i| if let Inline::Text(t, f) = i { Some((t, f.bold, f.italic)) } else { None }).collect()
}
#[test]
fn emphasis() {
assert_eq!(spans("a *b* c"), vec![("a ".into(), false, false), ("b".into(), false, true), (" c".into(), false, false)]);
assert_eq!(spans("**b**"), vec![("b".into(), true, false)]);
assert_eq!(spans("***x***"), vec![("x".into(), true, true)]);
assert_eq!(spans("*a **b** c*"), vec![("a ".into(), false, true), ("b".into(), true, true), (" c".into(), false, true)]);
assert_eq!(spans("snake_case_name"), vec![("snake_case_name".into(), false, false)]);
assert_eq!(spans("2 * 3 * 4"), vec![("2 * 3 * 4".into(), false, false)]);
assert_eq!(spans("**unclosed"), vec![("**unclosed".into(), false, false)]);
let s = inl("~~gone~~");
assert!(matches!(&s[0], Inline::Text(t, f) if t == "gone" && f.strike));
}
#[test]
fn links_code_escapes() {
let s = inl("see [the *site*](http://x.org \"T\") and `a*b*` \\*lit\\* &amp; <https://y.com>");
let link: Vec<_> = s
.iter()
.filter_map(|i| if let Inline::Text(t, f) = i { f.link.as_ref().map(|l| (t.clone(), l.to_string(), f.italic)) } else { None })
.collect();
assert_eq!(link[0], ("the ".into(), "http://x.org".into(), false));
assert_eq!(link[1], ("site".into(), "http://x.org".into(), true));
assert!(link.iter().any(|l| l.1 == "https://y.com"));
assert!(s.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "a*b*" && f.code)));
let text: String = s.iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t.as_str()) } else { None }).collect();
assert!(text.contains("*lit* & "), "{text}");
}
/// Reference links: `[text][label]`, `[text][]` and `[text]` take their destination from a
/// definition line, and the definition itself leaves the document. Before, none of the three
/// was a link and the definition stayed as a paragraph of its own (#3238).
#[test]
fn reference_links_resolve_and_their_definitions_leave_the_body() {
let links = |md: &str| -> Vec<(String, String)> {
match &*parse(md).blocks {
[FBlock::Para(p), ..] => p
.inlines
.iter()
.filter_map(|x| match x {
Inline::Text(t, f) => f.link.as_deref().map(|l| (t.clone(), l.to_string())),
_ => None,
})
.collect(),
_ => vec![],
}
};
let def = "\n\n[ref1]: https://example.com/ref\n";
let to_ref = |text: &str| vec![(text.to_string(), "https://example.com/ref".to_string())];
assert_eq!(links(&format!("[click here][ref1]{def}")), to_ref("click here"), "full");
assert_eq!(links(&format!("[ref1][]{def}")), to_ref("ref1"), "collapsed");
assert_eq!(links(&format!("[ref1]{def}")), to_ref("ref1"), "shortcut");
// Labels match case-folded with runs of white space collapsed (CommonMark §4.7).
assert_eq!(links(&format!("[click here][ REF1 ]{def}")), to_ref("click here"), "folded label");
// An undefined label is not a link: an ordinary bracketed phrase stays text.
assert_eq!(links(&format!("[nope]{def}")), vec![], "undefined");
// Inline links are unaffected.
assert_eq!(links("[x](https://inline.example)"), vec![("x".into(), "https://inline.example".into())], "inline");
// The definition is not a paragraph, and its text is nowhere in the body.
let doc = import(&format!("Reference link: [click here][ref1]{def}"));
let text = doc.plain_text(craft_wordmodel::StoryRef::Body);
assert_eq!(text, "Reference link: click here", "{text:?}");
// A definition inside a fence is code, not a definition.
let fenced = "```\n[ref1]: https://example.com/ref\n```\n\n[ref1]\n";
let body = import(fenced).plain_text(craft_wordmodel::StoryRef::Body);
assert!(body.contains("[ref1]: https://example.com/ref"), "the fenced definition is still code: {body:?}");
assert_eq!(links(fenced), vec![], "a fenced definition defines nothing");
}
/// Cached bracket pairs must not make undefined shortcut references normalize every nested
/// suffix again. One unrelated definition keeps this on the reference-lookup path.
#[test]
fn nested_undefined_reference_labels_have_bounded_work() {
let literal = format!("prefix {}x{} suffix", "[".repeat(128_000), "]".repeat(128_000));
let src = format!("{literal}\n\n[other]: https://example.com/other\n");
let started = std::time::Instant::now();
let doc = import(&src);
let elapsed = started.elapsed();
assert_eq!(doc.plain_text(craft_wordmodel::StoryRef::Body), literal);
assert!(elapsed < std::time::Duration::from_secs(10), "nested undefined references took {elapsed:?}");
}
#[test]
fn reference_lookup_keeps_long_unicode_labels_and_nested_text() {
// The work guard is not a label-length cap. Collapsed/full/shortcut references keep their
// case and whitespace matching, even when lowercasing changes the number of UTF-8 bytes.
let label = format!("{}İ END", "Écho ".repeat(1500));
let key = label.to_lowercase();
let src = format!("[{label}] [{label}][] [full][{label}]\n\n[{key}]: https://example.com/long\n");
let flow = parse(&src);
let FBlock::Para(p) = &flow.blocks[0] else { panic!("paragraph") };
let links: Vec<_> = p
.inlines
.iter()
.filter_map(|x| match x {
Inline::Text(t, f) if f.link.is_some() => Some((t.as_str(), f.link.as_deref())),
_ => None,
})
.collect();
assert_eq!(
links,
vec![
(label.as_str(), Some("https://example.com/long")),
(label.as_str(), Some("https://example.com/long")),
("full", Some("https://example.com/long"))
]
);
let refs = Refs::from([("known".into(), Arc::from("https://example.com/known"))]);
let out = parse_inlines("[outer [known]] tail [inline](https://example.com/inline)", &Fmt::default(), 0, &refs);
let text: String = out.iter().filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }).collect();
assert_eq!(text, "[outer known] tail inline");
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "known" && f.link.as_deref() == Some("https://example.com/known"))));
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "inline" && f.link.as_deref() == Some("https://example.com/inline"))));
}
/// A definition has to start a block (CommonMark §4.7), and a fence opens and closes by the
/// same rules the block parser uses. Reading a line without that context deleted text: a
/// definition-shaped line inside a paragraph, and every such line inside a code block whose
/// fence this pass thought had closed.
#[test]
fn a_definition_shaped_line_that_is_not_a_definition_stays_in_the_document() {
let body = |md: &str| import(md).plain_text(craft_wordmodel::StoryRef::Body);
// A definition cannot interrupt a paragraph: this is the author's text, lines and all.
let para = "Status update\n[Warning]: unstable\nmore text\n";
assert_eq!(body(para), "Status update [Warning]: unstable more text", "{:?}", body(para));
// `` ```rust `` holds an info string, so it does not close a fence: the definition-shaped
// line after it is still code.
let code = "```\n```rust\n[foo]: /bar\n```\n\nSee [foo].\n";
assert!(body(code).contains("[foo]: /bar"), "{:?}", body(code));
assert!(body(code).ends_with("See [foo]."), "nothing defines foo: {:?}", body(code));
// Four spaces of indentation is code, not a fence, so the definition that follows is
// taken and the reference resolves.
let indented = " ```\n\n[x]: /y\n\nSee [x].\n";
let doc = import(indented);
let text = doc.plain_text(craft_wordmodel::StoryRef::Body);
assert!(text.contains("```") && text.ends_with("See x."), "{text:?}");
// A definition still gives up its line after a blank line, at the start of the document,
// after a heading, and after another definition.
let starts = "[a]: /a\n[b]: /b\n\n# Head\n[c]: /c\n\nSee [a], [b], [c].\n";
assert_eq!(body(starts), "Head\nSee a, b, c.", "{:?}", body(starts));
// A footnote definition is not a link definition: it stays in the body.
let footnote = "x[^1]\n\n[^1]: Note\n";
assert!(body(footnote).contains("[^1]: Note"), "{:?}", body(footnote));
}
#[test]
fn blocks() {
let f = parse(
"# Title\n\nPara one\ncontinues.\n\n> quoted\n\n- a\n- b\n 1. nested\n\n```\ncode *x*\n```\n\n---\n\n| A | B |\n|---|:-:|\n| 1 | 2 |\n",
);
let kinds: Vec<String> = f
.blocks
.iter()
.map(|b| match b {
FBlock::Para(p) => format!("{:?}{:?}:{}", p.kind, p.list.map(|l| (l.ordered, l.level)), p.text()),
FBlock::Table(t) => format!("table{}", t.rows.len()),
})
.collect();
assert_eq!(
kinds,
vec![
"Heading(1)None:Title",
"NormalNone:Para one continues.",
"QuoteNone:quoted",
"NormalSome((false, 0)):a",
"NormalSome((false, 0)):b",
"NormalSome((true, 1)):nested",
"CodeNone:code *x*",
"RuleNone:",
"table2",
]
);
}
#[test]
fn export_escapes_round_trip() {
let mut p = Para::default();
p.push_text("1. not a list *really*", &Fmt::default());
let md = escape_line_start(inlines_md(&p.inlines));
let back = parse(&md);
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
assert_eq!(q.text(), "1. not a list *really*");
assert!(q.list.is_none());
}
#[test]
fn export_emphasis_next_to_punctuation_round_trips() {
let marks: [(&str, fn(&mut Fmt)); 3] = [("bold", |f| f.bold = true), ("italic", |f| f.italic = true), ("strike", |f| f.strike = true)];
let cases: [&[(&str, bool)]; 4] = [
&[("注意:", true), ("请阅读", false)],
&[("a.", true), ("b", false)],
&[("a", false), ("!", true), ("b", false)],
&[("a", false), ("(x)", true), ("b", false)],
];
for (name, set) in marks {
for runs in cases {
let mut p = Para::default();
for (t, on) in runs {
let mut f = Fmt::default();
if *on {
set(&mut f);
}
p.push_text(t, &f);
}
let md = inlines_md(&p.inlines);
let back = parse(&md);
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
let want: String = runs.iter().map(|r| r.0).collect();
assert_eq!(q.text(), want, "{name} exported as {md:?}");
let got: Vec<(String, bool)> = q
.inlines
.iter()
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.bold || f.italic || f.strike)) } else { None })
.collect();
let expect: Vec<(String, bool)> = runs.iter().map(|(t, on)| (t.to_string(), *on)).collect();
assert_eq!(got, expect, "{name} exported as {md:?}");
}
}
}
#[test]
fn export_adjacent_emphasis_next_to_punctuation_round_trips() {
let b = Fmt { bold: true, ..Fmt::default() };
let i = Fmt { italic: true, ..Fmt::default() };
let s = Fmt { strike: true, ..Fmt::default() };
let cases: [&[(&str, &Fmt)]; 6] = [
&[("ab", &b), ("!c", &i)],
&[("ab", &i), ("!c", &b)],
&[("ab", &b), ("!c", &s)],
&[("ab", &s), ("(c)", &b)],
&[("a.", &b), ("b", &i)],
&[("a.", &b), ("!b", &i)],
];
for runs in cases {
let mut p = Para::default();
for (t, f) in runs {
p.push_text(t, f);
}
let md = inlines_md(&p.inlines);
let back = parse(&md);
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
let got: Vec<(String, bool, bool, bool)> =
q.inlines.iter().filter_map(|x| if let Inline::Text(t, f) = x { Some((t.clone(), f.bold, f.italic, f.strike)) } else { None }).collect();
let expect: Vec<(String, bool, bool, bool)> = runs.iter().map(|(t, f)| (t.to_string(), f.bold, f.italic, f.strike)).collect();
assert_eq!(got, expect, "exported as {md:?}");
}
}
#[test]
fn escape_line_start_preserves_indentation() {
assert_eq!(escape_line_start(" indented text".to_string()), " indented text");
assert_eq!(escape_line_start(" # not a heading".to_string()), " \\# not a heading");
assert_eq!(escape_line_start(" 1. not a list".to_string()), " 1\\. not a list");
assert_eq!(escape_line_start(" > quote".to_string()), " \\> quote");
// Four spaces or a tab would make an indented code block: the indent is capped at three.
assert_eq!(escape_line_start(" deep".to_string()), " deep");
assert_eq!(escape_line_start("\tTabbed # text".to_string()), " Tabbed # text");
assert_eq!(escape_line_start(" # not a heading".to_string()), " \\# not a heading");
assert_eq!(escape_line_start(" \t1. not a list".to_string()), " 1\\. not a list");
}
#[test]
fn leading_unicode_whitespace_is_kept_and_does_not_start_blocks() {
let kinds_text = |md: &str| -> Vec<(Kind, bool, String)> {
parse(md).blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some((p.kind, p.list.is_some(), p.text())) } else { None }).collect()
};
assert_eq!(kinds_text("\u{3000}\u{3000}日本語の段落\n"), vec![(Kind::Normal, false, "\u{3000}\u{3000}日本語の段落".to_string())]);
for src in ["\u{a0}# not a heading", "\u{3000}1. not a list", "\u{a0}- x", "\u{a0}> not a quote"] {
let mut p = Para::default();
p.push_text(src, &Fmt::default());
let md = escape_line_start(inlines_md(&p.inlines));
assert_eq!(kinds_text(&md), vec![(Kind::Normal, false, src.to_string())], "{src:?} via {md:?}");
}
}
#[test]
fn single_backtick_code_span_escaped_pipe() {
let md = "| Command | Status |\n| --- | --- |\n| `cmd \\| grep` | pass |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[1].len(), 2);
let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() };
assert_eq!(p0.inlines.len(), 1);
assert!(matches!(&p0.inlines[0], Inline::Text(t, f) if t == "cmd | grep" && f.code));
let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() };
assert_eq!(p1.text(), "pass");
let mut out = Vec::new();
table_md(t, &mut out);
let exported = out.join("\n");
assert!(exported.contains("`cmd \\| grep`"), "Exported: {exported}");
}
#[test]
fn double_backtick_code_span_escaped_pipe() {
let md = "| Syntax | Note |\n| --- | --- |\n| ``a \\| b`` | second |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[1].len(), 2);
let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() };
assert_eq!(p0.inlines.len(), 1);
assert!(matches!(&p0.inlines[0], Inline::Text(t, f) if t == "a | b" && f.code));
let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() };
assert_eq!(p1.text(), "second");
}
#[test]
fn code_span_literal_backtick_and_escaped_pipe() {
let md = "| Expression | Detail |\n| --- | --- |\n| ``a`b\\|c`` | distinct |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[1].len(), 2);
let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() };
assert_eq!(p0.inlines.len(), 1);
assert!(matches!(&p0.inlines[0], Inline::Text(t, f) if t == "a`b|c" && f.code));
let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() };
assert_eq!(p1.text(), "distinct");
}
#[test]
fn three_column_table_with_distinctive_text() {
let md = "| Col1 | Col2 | Col3 |\n| --- | --- | --- |\n| `p1\\|q1` | Distinctive Two | Distinctive Three |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[1].len(), 3);
assert_eq!(t.rows[1][0].text(), "p1|q1");
assert_eq!(t.rows[1][1].text(), "Distinctive Two");
assert_eq!(t.rows[1][2].text(), "Distinctive Three");
let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() };
assert!(matches!(&p0.inlines[0], Inline::Text(_, f) if f.code));
let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() };
assert!(matches!(&p1.inlines[0], Inline::Text(_, f) if !f.code));
let FBlock::Para(p2) = &t.rows[1][2].blocks[0] else { panic!() };
assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code));
}
#[test]
fn table_cells_past_the_document_limits_are_not_built() {
// Every body row was padded to the header's width before any cap (#3701).
let wide = format!("{}|\n{}|\n|\n| b |\n", "|a".repeat(50_000), "|-".repeat(50_000));
craft_flow::begin_import();
let flow = parse(&wide);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 3);
assert!(t.rows.iter().all(|r| r.len() == MAX_COLS));
assert_eq!(t.rows[2][0].text(), "b");
assert!(craft_flow::Dropped::TableCells.reported(), "the columns left out are logged");
// Rows past the limit still belong to the table, so the text after it reads as before.
let tall = format!("| a |\n| - |\n{}\nafter\n", "| x |\n".repeat(MAX_ROWS + 10));
craft_flow::begin_import();
let flow = parse(&tall);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), MAX_ROWS);
let FBlock::Para(p) = &flow.blocks[1] else { panic!("expected paragraph") };
assert_eq!(p.text(), "after");
assert_eq!(flow.blocks.len(), 2);
assert!(craft_flow::Dropped::TableCells.reported(), "the rows left out are logged");
}
/// A short row is padded to its header's width, so a small file of one-cell rows under wide
/// headers made millions of cells (CodeRabbit on #3862). The document's tables share one cell
/// budget: rows past it are left out and logged, and their lines make no paragraphs.
#[test]
fn tables_share_one_cell_budget() {
let header = |n: usize| format!("|{}|\n|{}|\n", vec![" h "; n].join("|"), vec!["-"; n].join("|"));
let fits = crate::MAX_TABLE_CELLS / MAX_COLS;
let src = format!("{}{}\nafter\n\n{}| 1 | 2 |\n", header(MAX_COLS), "| x |\n".repeat(fits + 100), header(2));
craft_flow::begin_import();
let flow = parse(&src);
let tables: Vec<&FTable> = flow.blocks.iter().filter_map(|b| if let FBlock::Table(t) = b { Some(t) } else { None }).collect();
let cells: usize = tables.iter().flat_map(|t| &t.rows).map(Vec::len).sum();
assert!(cells <= crate::MAX_TABLE_CELLS, "{cells} cells");
assert_eq!(tables.iter().map(|t| t.rows.len()).collect::<Vec<_>>(), [fits], "the second table is past the budget");
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
assert_eq!(texts, ["after"]);
assert!(craft_flow::Dropped::TableCells.reported());
// Within the budget a table is whole and nothing is logged.
craft_flow::begin_import();
let flow = parse(&format!("{}| 1 | 2 |\n", header(2)));
let [FBlock::Table(t)] = flow.blocks.as_slice() else { panic!("{:?}", flow.blocks) };
assert_eq!(t.rows.len(), 2);
assert!(!craft_flow::Dropped::TableCells.reported());
}
#[test]
fn a_wide_table_still_checks_every_header_and_delimiter_cell() {
// Only the first 63 cells are kept, but every one is counted and checked.
let head = "|a".repeat(100) + "|\n";
let delim = |bad: Option<usize>| (0..100).map(|i| if Some(i) == bad { "|x" } else { "|-" }).collect::<String>() + "|\n";
let is_table = |md: String| matches!(parse(&md).blocks.first(), Some(FBlock::Table(_)));
assert!(is_table(format!("{head}{}| b |\n", delim(None))));
assert!(!is_table(format!("{head}{}| b |\n", delim(Some(80)))), "a bad delimiter cell past the kept ones");
assert!(!is_table(format!("{}|\n{}| b |\n", "|a".repeat(99), delim(None))), "99 header cells, 100 delimiter cells");
let (cells, n) = split_row(r"| a | b \| c | d |", 2);
assert_eq!((cells, n), (vec!["a".to_string(), "b | c".to_string()], 3));
}
#[test]
fn regular_table_cells_containing_escaped_pipes() {
let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[1].len(), 2);
assert_eq!(t.rows[1][0].text(), "Regular | Pipe");
assert_eq!(t.rows[1][1].text(), "Normal text cell");
let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() };
assert!(matches!(&p0.inlines[0], Inline::Text(_, f) if !f.code));
let mut out = Vec::new();
table_md(t, &mut out);
let exported = out.join("\n");
assert!(exported.contains("Regular \\| Pipe"), "Exported: {exported}");
assert!(!exported.contains("Regular \\\\| Pipe"), "Exported: {exported}");
}
#[test]
fn unescaped_pipes_behave_as_gfm_column_separators() {
let md = "| Header 1 | Header 2 |\n| --- | --- |\n| `raw|pipe` | Extra Column |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[1].len(), 2);
assert_eq!(t.rows[1][0].text(), "`raw");
assert_eq!(t.rows[1][1].text(), "pipe`");
}
#[test]
fn ordinary_tables_and_standalone_code_spans_unchanged() {
let md = "| Alpha | Beta |\n| --- | --- |\n| 1 | 2 |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!() };
let mut out = Vec::new();
table_md(t, &mut out);
assert_eq!(out.join("\n"), "| Alpha | Beta |\n| --- | --- |\n| 1 | 2 |");
let mut p = Para::default();
let f = Fmt { code: true, ..Default::default() };
p.push_text("cmd | grep", &f);
let exported = inlines_md(&p.inlines);
assert_eq!(exported, "`cmd | grep`");
}
#[test]
fn code_span_line_endings_stay_literal_on_export() {
let cases = [
("a\n# b", "a # b"),
("a\n<div>b</div>", "a <div>b</div>"),
("a\n- b", "a - b"),
("a\n```", "a ```"),
("a\r# b", "a # b"),
("a\r\n# b", "a # b"),
("a\n\n> b", "a > b"),
("\na\n", " a "),
("`a\n# b`", "`a # b`"),
("cmd | grep", "cmd | grep"),
];
let shapes = [
(Kind::Normal, None),
(Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() })),
(Kind::Code, Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() })),
];
for (source, expected) in cases {
for (kind, list) in shapes {
let mut p = Para::new(kind);
p.list = list;
p.push_text(source, &Fmt { code: true, ..Default::default() });
let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() });
// The flow builder removes CR controls; keep the native document's raw input.
let p = doc.para_mut(craft_wordmodel::StoryRef::Body, &craft_wordmodel::Path::top(0)).unwrap();
p.text = source.to_string();
p.runs[0].len = source.len();
let md = export(&doc);
assert_eq!(md.trim_end().lines().count(), 1, "{source:?} {kind:?} {list:?} -> {md:?}");
assert!(!md.contains('\r'), "{md:?}");
let back = import(&md);
let flow = model::from_doc(&back);
let [FBlock::Para(q)] = flow.blocks.as_slice() else { panic!("{md:?}") };
assert_eq!(q.text(), expected, "{source:?} {kind:?} {list:?} -> {md:?}");
assert_eq!(q.list, list, "{md:?}");
assert!(q.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}");
}
}
let fenced = "```\na\n# b\n```";
assert_eq!(export(&import(fenced)).trim_end(), fenced);
}
#[test]
fn code_span_line_endings_in_table_cells_stay_literal() {
for ending in ["\n", "\r", "\r\n"] {
let source = format!("a{ending}# b|c");
let mut p = Para::default();
p.push_text(&source, &Fmt { code: true, ..Default::default() });
let cell = Cell { blocks: vec![FBlock::Para(p)], ..Default::default() };
let table = FTable { rows: vec![vec![cell, Cell::default()], vec![Cell::default(), Cell::default()]], ..Default::default() };
let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Table(table)], ..Default::default() });
let p = doc.para_mut(craft_wordmodel::StoryRef::Body, &craft_wordmodel::Path(vec![0, 0, 0, 0])).unwrap();
p.text = source.clone();
p.runs[0].len = source.len();
let md = export(&doc);
let back = model::from_doc(&import(&md));
let [FBlock::Table(table), ..] = back.blocks.as_slice() else { panic!("{md:?}") };
assert!(back.blocks.iter().skip(1).all(|b| matches!(b, FBlock::Para(p) if p.text().is_empty())), "{md:?}");
assert_eq!(table.rows.len(), 2, "{md:?}");
assert_eq!(table.rows[0].len(), 2, "{md:?}");
let [FBlock::Para(p)] = table.rows[0][0].blocks.as_slice() else { panic!("{md:?}") };
assert_eq!(p.text(), "a # b|c", "{md:?}");
assert!(p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}");
}
}
#[test]
fn table_code_pipe_export_import_round_trip() {
let mut table = FTable::default();
let mut cell_a = Cell::default();
let mut para_a = Para::default();
let fmt_a = Fmt { code: true, ..Default::default() };
para_a.push_text("a`b|c", &fmt_a);
cell_a.blocks.push(FBlock::Para(para_a));
let mut cell_b = Cell::default();
let mut para_b = Para::default();
para_b.push_text("CRITICAL_DISTINCTIVE_INFO", &Fmt::default());
cell_b.blocks.push(FBlock::Para(para_b));
let mut head_a = Cell::default();
let mut head_para_a = Para::default();
head_para_a.push_text("Col A", &Fmt::default());
head_a.blocks.push(FBlock::Para(head_para_a));
let mut head_b = Cell::default();
let mut head_para_b = Para::default();
head_para_b.push_text("Col B", &Fmt::default());
head_b.blocks.push(FBlock::Para(head_para_b));
table.rows.push(vec![head_a, head_b]);
table.rows.push(vec![cell_a, cell_b]);
let mut out = Vec::new();
table_md(&table, &mut out);
let exported_md = out.join("\n");
assert!(exported_md.contains("``a`b\\|c``"), "Exported: {exported_md}");
assert!(exported_md.contains("CRITICAL\\_DISTINCTIVE\\_INFO"), "Exported: {exported_md}");
let back_flow = parse(&exported_md);
let FBlock::Table(back_table) = &back_flow.blocks[0] else { panic!("expected table") };
assert_eq!(back_table.rows.len(), 2, "Both rows must exist");
let row1 = &back_table.rows[1];
assert_eq!(row1.len(), 2, "Both cells must exist in row 1");
let back_cell_a = &row1[0];
let back_cell_b = &row1[1];
assert_eq!(back_cell_a.text(), "a`b|c", "Cell A text must be preserved");
assert_eq!(back_cell_b.text(), "CRITICAL_DISTINCTIVE_INFO", "Cell B text must be preserved");
let FBlock::Para(back_p_a) = &back_cell_a.blocks[0] else { panic!() };
assert_eq!(back_p_a.inlines.len(), 1);
assert!(
matches!(&back_p_a.inlines[0], Inline::Text(t, f) if t == "a`b|c" && f.code),
"Code formatting must remain intact on Cell A: {:?}",
back_p_a.inlines
);
let FBlock::Para(back_p_b) = &back_cell_b.blocks[0] else { panic!() };
assert_eq!(back_p_b.inlines.len(), 1);
assert!(
matches!(&back_p_b.inlines[0], Inline::Text(t, f) if t == "CRITICAL_DISTINCTIVE_INFO" && !f.code),
"Cell B formatting must remain plain text: {:?}",
back_p_b.inlines
);
assert_eq!(back_cell_a.text(), "a`b|c");
assert_eq!(back_cell_b.text(), "CRITICAL_DISTINCTIVE_INFO");
assert!(!back_cell_a.text().contains("CRITICAL_DISTINCTIVE_INFO"));
assert!(!back_cell_b.text().contains("a`b|c"));
let doc = import(&exported_md);
let re_exported = export(&doc);
assert!(re_exported.contains("``a`b\\|c``"), "Re-exported: {re_exported}");
assert!(re_exported.contains("CRITICAL\\_DISTINCTIVE\\_INFO"), "Re-exported: {re_exported}");
let doc2 = import(&re_exported);
let back_flow2 = model::from_doc(&doc2);
let FBlock::Table(back_table2) = &back_flow2.blocks[0] else { panic!() };
assert_eq!(back_table2.rows[1][0].text(), "a`b|c");
assert_eq!(back_table2.rows[1][1].text(), "CRITICAL_DISTINCTIVE_INFO");
}
#[test]
fn ordered_list_keeps_start_number() {
assert_eq!(export(&import("5. a\n6. b")).trim_end(), "5. a\n6. b");
}
/// A list inside a block quote stays in the quote: its items were imported as quoted list
/// paragraphs but written back without `>`, so the list moved out of the quote.
#[test]
fn a_list_inside_a_quote_stays_quoted() {
let src = "> Intro\n>\n> - one\n> - two\n> - nested\n> 1. first\n\nAfter.\n";
let want = "> Intro\n\n> - one\n> - two\n> - nested\n> 1. first\n\nAfter.";
let md = export(&import(src));
assert_eq!(md.trim_end(), want);
assert_eq!(export(&import(&md)).trim_end(), want, "and stays so when read back");
// An item's continuation line is quoted too.
assert_eq!(export(&import("> - a\\\n> b\n")).trim_end(), "> - a\\\n> b");
// A list outside a quote is unchanged.
assert_eq!(export(&import("- one\n- two\n")).trim_end(), "- one\n- two");
// A list after a quoted list stays outside the quote.
let md = export(&import("> - a\n\n2. b\n"));
assert_eq!(md.trim_end(), "> - a\n\n2. b");
assert_eq!(export(&import(&md)).trim_end(), "> - a\n\n2. b", "and stays so when read back");
}
#[test]
fn lines_after_line_break_stay_in_paragraph() {
use craft_wordmodel::{CharProps, Document, Paragraph, StoryRef, para_block};
let cases = ["Shopping\n- milk\n# not a heading", "a\n1. x", "a\n2) x", "a\n+ x", "a\n---", "a\n***", "a\n==", "a\n--", "a\n> q"];
for s in cases {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text(s, CharProps::default()))];
let md = export(&d);
let back = import(&md);
assert_eq!(back.body.len(), 1, "{s:?} -> {md:?}");
assert_eq!(back.plain_text(StoryRef::Body), s, "{md:?}");
}
}
#[test]
fn lines_after_line_break_stay_in_list_item_and_quote() {
let cases = ["a\n- x", "a\n# h", "a\n1. x", "a\n--", "a\n---", "a\n==", "a\n> q"];
let shapes: [(Kind, Option<ListInfo>); 5] = [
(Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() })),
(Kind::Normal, Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() })),
(Kind::Normal, Some(ListInfo { ordered: false, level: 1, start: 1, ..Default::default() })),
(Kind::Normal, Some(ListInfo { ordered: true, level: 1, start: 1, ..Default::default() })),
(Kind::Quote, None),
];
for s in cases {
for (kind, list) in shapes {
let mut blocks = Vec::new();
if let Some(li) = list.filter(|l| l.level > 0) {
let mut parent = Para::new(Kind::Normal);
parent.list = Some(ListInfo { ordered: li.ordered, level: 0, start: 1, ..Default::default() });
parent.push_text("top", &Fmt::default());
blocks.push(FBlock::Para(parent));
}
let mut p = Para::new(kind);
p.list = list;
p.push_text(s, &Fmt::default());
blocks.push(FBlock::Para(p));
let mut out = Vec::new();
write_blocks(&blocks, &mut out);
let md = out.join("\n");
let back = parse(&md);
assert_eq!(back.blocks.len(), blocks.len(), "{s:?} {kind:?} {list:?} -> {md:?}");
let Some(FBlock::Para(q)) = back.blocks.last() else { panic!("{md:?}") };
assert_eq!(q.kind, kind, "{md:?}");
assert_eq!(q.list, list, "{md:?}");
assert_eq!(q.text(), s, "{md:?}");
}
}
}
#[test]
fn table_column_alignments_import_and_export() {
let md = "| Left | Center | Right | Default |\n| :--- | :---: | ---: | --- |\n| 1 | 2 | 3 | 4 |\n";
let flow = parse(md);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 2);
let aligns: Vec<Option<Align>> =
t.rows[0].iter().map(|c| c.blocks.iter().find_map(|b| if let FBlock::Para(p) = b { Some(p.align) } else { None }).flatten()).collect();
assert_eq!(aligns, vec![Some(Align::Left), Some(Align::Center), Some(Align::Right), None]);
let mut out = Vec::new();
table_md(t, &mut out);
let exported = out.join("\n");
assert!(exported.contains("| :--- | :---: | ---: | --- |"), "Exported: {exported}");
}
#[test]
fn tab_separated_list_markers() {
let f = parse("-\tbullet item\n*\tstar item\n+\tplus item\n1.\tone item\n2)\ttwo item\n");
assert_eq!(f.blocks.len(), 5);
for (i, b) in f.blocks.iter().enumerate() {
let FBlock::Para(p) = b else { panic!("block {i} not a para") };
assert!(p.list.is_some(), "block {i} should be a list item: {:?}", p);
}
let FBlock::Para(p0) = &f.blocks[0] else { panic!() };
assert_eq!(p0.text(), "bullet item");
assert!(!p0.list.unwrap().ordered);
let FBlock::Para(p3) = &f.blocks[3] else { panic!() };
assert_eq!(p3.text(), "one item");
assert!(p3.list.unwrap().ordered);
}
#[test]
fn html_comments_are_ignored_in_blocks_and_inlines() {
let md = "<!-- block comment -->\n\nHello <!-- inline comment -->world!\n\n<!--\nmultiline\ncomment\n-->\n\nAfter comment.";
let flow = parse(md);
assert_eq!(flow.blocks.len(), 2);
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
assert_eq!(texts, ["Hello world!", "After comment."]);
}
#[test]
fn a_paragraph_of_delimiters_parses_in_linear_time() {
// #3714: each delimiter's literal looked back for the text before it, to the start of
// the paragraph when there was none (quadratic).
let src = "*_".repeat(200_000);
let t = std::time::Instant::now();
let out = inl(&src);
// Under a second on a loaded machine; the backwards search ran past 6 minutes.
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
let text: String = out.iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t.as_str()) } else { None }).collect();
assert!(text.chars().all(|c| matches!(c, '*' | '_')), "only delimiters come out");
// Literal delimiters still take the link of the text before them.
let s = inl("[a](http://x.org) [b*](http://y.org)");
assert!(s.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "b*" && f.link.as_deref() == Some("http://y.org"))), "{s:?}");
}
#[test]
fn exported_link_destinations_read_back_unchanged() {
// Removing `<` and `>` from a destination after the safety check could make a relative
// target a script or network-path one (`java<script:…` → `javascript:…`). They are
// escaped instead, as are backslash escapes and entity references a reader would decode.
let md = |l: &str| inlines_md(&[Inline::Text("x".into(), Fmt { link: Some(l.into()), ..Default::default() })]);
assert_eq!(md("java<script:alert(1)"), "[x](<java\\<script:alert(1)>)");
for l in [
"java<script:alert(1)",
"/</evil.example/(x)",
"a>b c",
"&#106;avascript:alert(1)",
"&#x6A;avascript:x",
"&amp;",
"javascript\\:x",
"dir\\",
"dir\\(1)\\",
"q?a=1&b=2",
] {
let out = md(l);
let back = inl(&out);
let got: Vec<_> = back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect();
assert_eq!(got, [l.to_string()], "{l:?} → {out:?}");
}
// Ordinary destinations are written as before.
assert_eq!(md("https://example.com/a?b=1&c=2"), "[x](https://example.com/a?b=1&c=2)");
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
}
#[test]
fn heading_trailing_hash_survives_round_trip() {
let heading = |md: &str| {
let f = parse(md);
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("no paragraph in {md:?}") };
assert_eq!(p.kind, Kind::Heading(2), "{md:?}");
p.text()
};
for (src, text, want) in [
("## Item \\#\n", "Item #", "## Item \\#\n"),
("## Issue \\##\n", "Issue ##", "## Issue \\##\n"),
("## \\#\n", "#", "## \\#\n"),
("## C#\n", "C#", "## C#\n"),
] {
assert_eq!(heading(src), text, "{src:?}");
let md = export(&import(src));
assert_eq!(md, want, "first export of {src:?}");
assert_eq!(heading(&md), text, "md was {md:?}");
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
}
}