use edamame::config::Theme;
use edamame::markdown::parser::parse;
use edamame::markdown::renderer::Renderer;
use edamame::markdown::table_layout::str_cells;
use proptest::prelude::*;
fn render_at(md: &str, viewport: usize) -> Vec<ratatui::text::Line<'static>> {
let theme = Theme::default();
let blocks = parse(md);
Renderer::new(&theme)
.with_viewport_width(viewport)
.render(&blocks)
}
fn render(md: &str) -> Vec<ratatui::text::Line<'static>> {
render_at(md, 80)
}
fn line_text(line: &ratatui::text::Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn h1_contains_text() {
let lines = render("# Heading One\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Heading One"), "got: {text:?}");
}
#[test]
fn h1_has_rule_line() {
let lines = render("# H1\n");
let rule_line = lines.iter().find(|l| line_text(l).contains('─'));
assert!(rule_line.is_some(), "H1 should produce a ─── rule line");
}
#[test]
fn h2_contains_text() {
let lines = render("## Heading Two\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Heading Two"));
}
#[test]
fn h3_contains_text() {
let lines = render("### Three\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Three"));
}
#[test]
fn h4_contains_text() {
let lines = render("#### Four\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Four"));
}
#[test]
fn h5_contains_text() {
let lines = render("##### Five\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Five"));
}
#[test]
fn h6_contains_text() {
let lines = render("###### Six\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Six"));
}
#[test]
fn bold_text_present() {
let lines = render("**bold text**\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("bold text"), "got: {text:?}");
}
#[test]
fn italic_text_present() {
let lines = render("*italic text*\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("italic text"), "got: {text:?}");
}
#[test]
fn inline_code_present() {
let lines = render("`code_here`\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("code_here"), "got: {text:?}");
}
#[test]
fn fenced_code_block_content() {
let lines = render("```rust\nfn main() {}\n```\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("fn main()"), "got: {text:?}");
}
#[test]
fn fenced_code_block_has_background() {
let lines = render("```\nhello\n```\n");
let body_text = line_text(&lines[1]);
assert!(
body_text.contains("hello"),
"Expected code content, got: {body_text:?}"
);
let all_text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(
!all_text.contains('╭'),
"Unexpected box border: {all_text:?}"
);
}
#[test]
fn fenced_code_block_language_tag() {
let lines = render("```python\npass\n```\n");
let first_text = line_text(&lines[0]);
assert!(
first_text.contains("python"),
"Expected language label, got: {first_text:?}"
);
}
#[test]
fn blockquote_has_bar() {
let lines = render("> A blockquote\n");
let first_text = line_text(&lines[0]);
assert!(
first_text.contains('▎'),
"Expected ▎ bar, got: {first_text:?}"
);
}
#[test]
fn blockquote_contains_text() {
let lines = render("> A blockquote\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("A blockquote"), "got: {text:?}");
}
#[test]
fn blockquote_preserves_inline_styles() {
let theme = Theme::default();
let md = "> plain **bold** *italic* `code` ==mark== ~~struck~~ [link](https://example.com)\n";
let lines = render(md);
let spans = &lines[0].spans;
let span_for = |needle: &str| {
spans
.iter()
.find(|s| s.content.contains(needle))
.unwrap_or_else(|| panic!("no span for {needle:?} in {spans:?}"))
};
assert_eq!(
span_for("bold").style.add_modifier,
theme.bold.add_modifier,
"bold lost its modifier inside a quote"
);
assert_eq!(
span_for("italic").style.add_modifier,
theme.italic.add_modifier,
"italic lost its modifier inside a quote"
);
assert_eq!(
span_for("code").style.fg,
theme.code_span.fg,
"a code span lost its color inside a quote"
);
assert_eq!(
span_for("code").style.bg,
theme.code_span.bg,
"a code span lost its own surface inside a quote"
);
assert_eq!(
span_for("mark").style.bg,
theme.highlight.bg,
"a highlight lost its background inside a quote"
);
assert_eq!(
span_for("struck").style.add_modifier,
theme.strikethrough.add_modifier,
"strikethrough lost its modifier inside a quote"
);
assert_eq!(
span_for("link").style.fg,
theme.link_text.fg,
"a link lost its color inside a quote"
);
assert_eq!(lines[0].style.bg, theme.blockquote_text.bg);
assert_eq!(span_for("plain").style.bg, theme.blockquote_text.bg);
assert_eq!(span_for("▎").style.fg, theme.blockquote_bar.fg);
}
#[test]
fn blockquote_text_is_not_italic_by_default() {
let theme = Theme::default();
assert!(
!theme
.blockquote_text
.add_modifier
.contains(ratatui::style::Modifier::ITALIC),
"blockquote text should carry no blanket italic"
);
assert!(
theme.blockquote_text.bg.is_some(),
"blockquote text should carry a background wash"
);
}
#[test]
fn code_block_inside_a_blockquote_keeps_the_code_surface() {
let theme = Theme::default();
let lines = render("> ```\n> fn main() {}\n> ```\n");
let body = lines
.iter()
.find(|l| line_text(l).contains("fn main"))
.expect("code body row");
assert_eq!(body.style.bg, theme.code_block_text.bg);
}
#[test]
fn bullet_list_marker() {
let lines = render("- item one\n- item two\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains('•'), "Expected bullet •, got: {text:?}");
assert!(text.contains("item one"));
assert!(text.contains("item two"));
}
#[test]
fn ordered_list_numbers() {
let lines = render("1. first\n2. second\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("1."), "got: {text:?}");
assert!(text.contains("2."), "got: {text:?}");
}
#[test]
fn a_list_items_first_paragraph_renders_one_row_per_source_line() {
let lines = render("- a\n soft\n- b\\\n hard\n1. c\nlazy\n");
let text: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(
text,
["• a", " soft", "• b", " hard", "1. c", " lazy"],
"{text:?}"
);
}
#[test]
fn a_break_inside_an_inline_splits_the_row() {
let rows = |md: &str, reflow: bool| -> Vec<String> {
let theme = Theme::default();
Renderer::new(&theme)
.with_viewport_width(80)
.with_reflow_paragraphs(reflow)
.render(&parse(md))
.iter()
.map(line_text)
.collect()
};
assert_eq!(rows("a *b\nc* d\n", false), ["a b", "c d"]);
assert_eq!(rows("a *b\nc* d\n", true), ["a b c d"]);
assert_eq!(rows("[x\ny](u) ~~p\nq~~\n", false), ["x", "y p", "q"]);
for reflow in [false, true] {
assert_eq!(rows("*a\\\nb*\n", reflow), ["a", "b"], "reflow {reflow}");
assert_eq!(
rows("- **a\\\n b** c\n", reflow),
["• a", " b c"],
"reflow {reflow}"
);
}
}
#[test]
fn a_split_inline_keeps_its_style_on_both_rows() {
let theme = Theme::default();
let lines = Renderer::new(&theme)
.with_viewport_width(80)
.render(&parse("*a\nb* [c\nd](u)\n"));
let styled = |row: usize, text: &str| {
lines[row]
.spans
.iter()
.find(|s| s.content == text)
.map(|s| s.style)
.unwrap_or_else(|| panic!("no {text:?} on row {row}: {:?}", lines[row]))
};
assert_eq!(styled(0, "a"), styled(1, "b"));
assert_eq!(styled(1, "c"), styled(2, "d"));
}
#[test]
fn a_blockquote_renders_one_row_per_source_line() {
for (src, rows) in [
("> q\n> ```\n> x\n> ```\n", &["▎ q", "▎", "▎ x", "▎"][..]),
("> a\n>\n>\n> b\n", &["▎ a", "▎", "▎", "▎ b"]),
(">\n", &["▎"]),
(">\n> a\n>\n", &["▎", "▎ a", "▎"]),
("> [d]: /url\n> b\n", &["▎ b"]),
("> b\n>\n> [d]: /url\n", &["▎ b", "▎"]),
(
"> - a\n>\n> - b\n>\n> tail\n",
&["▎ • a", "▎", "▎ • b", "▎", "▎ tail"],
),
] {
let text: Vec<String> = render(src)
.iter()
.map(|l| line_text(l).trim_end().to_owned())
.collect();
assert_eq!(text, rows, "{src:?}");
}
}
#[test]
fn horizontal_rule() {
let lines = render("---\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains('─'), "Expected ─ rule, got: {text:?}");
}
#[test]
fn snapshot_all_headings() {
let md = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6\n";
let lines = render(md);
let output: Vec<String> = lines.iter().map(line_text).collect();
insta::assert_debug_snapshot!(output);
}
#[test]
fn snapshot_inline_formatting() {
let md = "Normal **bold** *italic* `code` ~~strike~~\n";
let lines = render(md);
let output: Vec<String> = lines.iter().map(line_text).collect();
insta::assert_debug_snapshot!(output);
}
#[test]
fn snapshot_code_block() {
let md = "```rust\nfn hello() {\n println!(\"hi\");\n}\n```\n";
let lines = render(md);
let output: Vec<String> = lines.iter().map(line_text).collect();
insta::assert_debug_snapshot!(output);
}
#[test]
fn snapshot_blockquote() {
let md = "> This is a blockquote.\n> Second line.\n";
let lines = render(md);
let output: Vec<String> = lines.iter().map(line_text).collect();
insta::assert_debug_snapshot!(output);
}
#[test]
fn snapshot_bullet_list() {
let md = "- Alpha\n- Beta\n- Gamma\n";
let lines = render(md);
let output: Vec<String> = lines.iter().map(line_text).collect();
insta::assert_debug_snapshot!(output);
}
#[test]
fn empty_task_item_renders_checkbox() {
let lines = render("- [ ] ");
let output: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(
output.len(),
1,
"expected one rendered line, got: {output:?}"
);
assert!(
output[0].contains("[ ]"),
"empty task item should render with '[ ]', got: {output:?}"
);
}
#[test]
fn task_item_with_non_paragraph_first_block_renders_checkbox() {
let lines = render("- [ ] \n ```\n x\n ```\n");
let output: Vec<String> = lines.iter().map(line_text).collect();
assert!(
output[0].contains("[ ]"),
"task item with non-paragraph first block should render '[ ]', got: {output:?}"
);
}
#[test]
fn empty_task_item_in_middle_of_list_renders_checkbox() {
let lines = render("- [ ] alpha\n- [ ] \n- [ ] beta\n");
let output: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(
output.len(),
3,
"expected three rendered lines, got: {output:?}"
);
for line in &output {
assert!(
line.contains("[ ]"),
"every task item should render with '[ ]', got: {output:?}"
);
}
}
#[test]
fn single_blank_between_list_items_is_rendered_as_a_blank_line() {
use edamame::document::ParsedDoc;
let theme = Box::leak(Box::new(Theme::default())) as &'static Theme;
let pd = ParsedDoc::build("- a\n- b\n\n- \n- c\n", theme, true, 24);
let texts: Vec<String> = (0..pd.line_count())
.map(|i| {
pd.lines[i]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
})
.collect();
assert!(
texts.iter().any(|t| t.is_empty()),
"expected a blank rendered line for the single source blank, got: {texts:?}"
);
}
#[test]
fn loose_list_blank_rows_keep_reveal_1to1_with_source_lines() {
use edamame::document::ParsedDoc;
let theme = Box::leak(Box::new(Theme::default())) as &'static Theme;
let src = "1. a\n2. b\n\n3. c\n\n4. d\n";
let pd = ParsedDoc::build(src, theme, true, 40);
let range = pd
.source_map
.original_range_for_byte(0)
.expect("list block range");
let block_src = &src[range.clone()];
let source_lines = block_src.trim_end_matches('\n').split('\n').count();
let rendered = pd.source_map.rendered_lines_for_byte(0);
assert_eq!(
rendered.end - rendered.start,
source_lines,
"loose list must own one rendered line per source line (src {block_src:?})"
);
}
#[test]
fn ordered_list_with_blank_gap_stays_one_loose_list_and_keeps_the_blank() {
use edamame::document::ParsedDoc;
let theme = Box::leak(Box::new(Theme::default())) as &'static Theme;
let pd = ParsedDoc::build("1. a\n2. b\n\n1. c\n", theme, true, 24);
let texts: Vec<String> = (0..pd.line_count())
.map(|i| {
pd.lines[i]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
})
.collect();
assert!(
texts.iter().any(|t| t.starts_with("3. c")),
"expected the loose list to number sequentially (3. c), got: {texts:?}"
);
assert!(
!texts.iter().any(|t| t.starts_with("1. c")),
"tail should not restart at 1 in a single loose list, got: {texts:?}"
);
let b_idx = texts.iter().position(|t| t.starts_with("2. b")).unwrap();
let c_idx = texts.iter().position(|t| t.starts_with("3. c")).unwrap();
assert!(
texts[b_idx + 1..c_idx].iter().any(|t| t.is_empty()),
"expected a blank row between the two items, got: {texts:?}"
);
}
#[test]
fn list_item_with_code_block_containing_blank_line_renders_without_gap() {
let md = "- intro\n ```toml\n [a]\n\n [b]\n ```\n trailing\n- next item\n";
let lines = render(md);
let texts: Vec<String> = lines.iter().map(line_text).collect();
let trailing_idx = texts
.iter()
.position(|s| s.contains("trailing"))
.expect("trailing continuation line missing");
let next_idx = texts
.iter()
.position(|s| s.contains("next item"))
.expect("next item line missing");
assert_eq!(
next_idx,
trailing_idx + 1,
"next bullet should sit on the line immediately after the trailing continuation, got: {texts:?}"
);
}
#[test]
fn snapshot_horizontal_rule() {
let lines = render("---\n");
let output: Vec<String> = lines.iter().map(line_text).collect();
insta::assert_debug_snapshot!(output);
}
#[test]
fn footnote_reference_renders_as_bracketed_marker() {
let lines = render("Claim.[^1]\n\n[^1]: Source.\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("[1]"), "expected [1] marker, got: {text:?}");
assert!(
!text.contains("[^1]"),
"raw reference syntax leaked: {text:?}"
);
}
#[test]
fn adjacent_footnote_references_fuse_into_one_marker() {
let lines = render("Two.[^1][^2]\n\n[^1]: one.\n\n[^2]: two.\n");
let para = line_text(&lines[0]);
assert!(para.contains("[1,2]"), "expected [1,2], got: {para:?}");
assert!(
!para.contains("[1][2]"),
"adjacent references should fuse: {para:?}"
);
}
#[test]
fn three_adjacent_footnote_references_fuse_into_one_marker() {
let lines = render("T.[^1][^2][^3]\n\n[^1]: a.\n\n[^2]: b.\n\n[^3]: c.\n");
let para = line_text(&lines[0]);
assert!(para.contains("[1,2,3]"), "expected [1,2,3], got: {para:?}");
}
#[test]
fn spaced_footnote_references_stay_separate_markers() {
let lines = render("Two.[^1] [^2]\n\n[^1]: one.\n\n[^2]: two.\n");
let para = line_text(&lines[0]);
assert!(para.contains("[1] [2]"), "expected [1] [2], got: {para:?}");
}
#[test]
fn footnote_marker_is_plain_ascii() {
let lines = render("Claim.[^1][^note]\n\n[^1]: a.\n\n[^note]: b.\n");
let para = line_text(&lines[0]);
assert!(para.is_ascii(), "footnote marker left ASCII: {para:?}");
}
#[test]
fn footnote_definition_renders_with_back_link_and_number() {
let lines = render("Claim.[^1]\n\n[^1]: The source text.\n");
let def_line = lines
.iter()
.map(line_text)
.find(|t| t.contains("The source text."))
.expect("definition line missing");
assert!(
def_line.starts_with(" 1. "),
"expected ` <label>. ` leader: {def_line:?}"
);
assert!(
def_line.trim_end().ends_with('↩'),
"expected trailing back-link glyph: {def_line:?}"
);
let body_pos = def_line.find("The source text.").expect("body");
let glyph_pos = def_line.find('↩').expect("glyph");
assert!(
glyph_pos > body_pos,
"glyph should be after the body: {def_line:?}"
);
}
#[test]
fn footnote_marker_matches_raw_label_without_renumbering() {
let lines = render("A[^3] B[^1]\n\n[^1]: one.\n\n[^3]: three.\n");
let para = line_text(&lines[0]);
let pos3 = para.find("[3]").expect("[3] marker");
let pos1 = para.find("[1]").expect("[1] marker");
assert!(
pos3 < pos1,
"markers follow source order, not a remap: {para:?}"
);
}
#[test]
fn footnotes_render_in_place_not_only_at_end() {
let lines = render("A.[^1]\n\n[^1]: Mid.\n\nLater paragraph.\n");
let texts: Vec<String> = lines.iter().map(line_text).collect();
let def_idx = texts.iter().position(|t| t.contains("Mid.")).expect("def");
let later_idx = texts
.iter()
.position(|t| t.contains("Later paragraph."))
.expect("later");
assert!(
def_idx < later_idx,
"definition should render before the later paragraph: {texts:?}"
);
}
#[test]
fn code_block_render_agrees_with_its_row_origins() {
use edamame::document::row_map::{raw_to_rendered_col, RawPos};
use edamame::document::ParsedDoc;
let theme = Box::leak(Box::new(Theme::default()));
for (md, raw_line, fenced) in [
("```rust\nlet x = 1;\n```\n", "let x = 1;", true),
("Intro.\n\n let x = 1;\n", " let x = 1;", false),
(
"```rust\nfn f() { g(\"s\"); }\n```\n",
"fn f() { g(\"s\"); }",
true,
),
(
"```rust\nlet s = \"héllo\"; // 日本語\n```\n",
"let s = \"héllo\"; // 日本語",
true,
),
] {
warm_fence_languages(md);
for highlighting in [false, true] {
let doc = ParsedDoc::build_with_overrides(
md,
theme,
true,
24,
None,
None,
false,
80,
false,
highlighting,
true,
false,
None,
);
let needle = raw_line.trim_start();
let idx = doc
.lines
.iter()
.position(|l| line_text(l).contains(needle))
.unwrap_or_else(|| panic!("code body row must render ({highlighting})"));
let rendered_chars: Vec<char> = line_text(&doc.lines[idx]).chars().collect();
let byte = doc.source_map.original_byte_for_rendered_line(idx).unwrap();
let block = doc.source_map.block_for_byte(byte).unwrap();
let row = idx - doc.source_map.rendered_lines_for_block(block).start;
let (line, lead) = if fenced { (1, 0) } else { (0, 4) };
for (raw_col, expected) in raw_line.chars().enumerate() {
if raw_col < lead {
continue;
}
let pos = RawPos {
line,
col: raw_col - lead,
};
let col = raw_to_rendered_col(&doc, block, row, pos).unwrap();
assert_eq!(
rendered_chars.get(col).copied(),
Some(expected),
"highlighting {highlighting}: raw col {raw_col} of {raw_line:?} should \
render at col {col}, rendered row is {rendered_chars:?}",
);
}
}
}
}
#[test]
fn highlighting_changes_no_text_and_no_row_count() {
for md in [
"```rust\nfn main() {\n let s = \"héllo 日本語\";\n}\n```\n",
"```rust\n/* block\n comment */\nlet x = 1;\n```\n",
"```python\n\n\nx = 1\n\n```\n",
"```rust\n\tlet tabbed = 1;\n```\n",
] {
let plain = render(md);
let lit = render_highlighted(md);
assert_eq!(plain.len(), lit.len(), "row count differs for {md:?}");
for (i, (a, b)) in plain.iter().zip(&lit).enumerate() {
assert_eq!(
line_text(a),
line_text(b),
"row {i} text differs for {md:?}"
);
}
}
}
#[test]
fn yaml_frontmatter_renders_one_verbatim_row_per_source_line() {
let md = "---\ntitle: Foo\n\ntags:\n - a\n---\n\n# Heading\n";
let lines = render(md);
let rendered: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(
&rendered[..6],
&["---", "title: Foo", "", "tags:", " - a", "---"],
"got: {rendered:?}",
);
}
#[test]
fn toml_frontmatter_keeps_its_pluses_delimiters() {
let lines = render("+++\ntitle = \"Foo\"\n+++\n\n# H\n");
let rendered: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(
&rendered[..3],
&["+++", "title = \"Foo\"", "+++"],
"got: {rendered:?}",
);
}
#[test]
fn frontmatter_is_not_rendered_as_a_rule_plus_heading() {
let lines = render("---\ntitle: Foo\ndate: 2026-01-01\n---\n\nBody.\n");
let theme = Theme::default();
for line in &lines {
let text = line_text(line);
assert!(
!text.contains("────"),
"frontmatter must not render a rule: {text:?}",
);
assert!(
line.spans.iter().all(|s| s.style != theme.h2),
"frontmatter must not render as a heading: {text:?}",
);
}
}
#[test]
fn a_lone_dash_rule_is_still_a_horizontal_rule() {
for md in [
"Intro.\n\n---\n\nBody.\n",
"---\ntitle: Foo\n\n# H\n",
"Intro.\n\n---\n## Section 2\n\nText.\n\n---\n## Section 3\n",
] {
let lines = render(md);
assert!(
lines.iter().any(|l| line_text(l).contains("────")),
"{md:?} should still render a rule, got: {:?}",
lines.iter().map(line_text).collect::<Vec<_>>(),
);
}
}
#[test]
fn frontmatter_does_not_claim_a_later_pair_of_the_other_flavor() {
let lines = render("+++\na = 1\n+++\n\n---\nSection\n---\n\nEnd.\n");
let rendered: Vec<String> = lines.iter().map(line_text).collect();
assert_eq!(
&rendered[..3],
&["+++", "a = 1", "+++"],
"got: {rendered:?}"
);
assert!(
rendered.iter().any(|l| l.contains("────")),
"the later `---` should still render a rule, got: {rendered:?}",
);
}
#[test]
fn frontmatter_key_and_value_are_styled_apart() {
let theme = Theme::default();
let lines = render("---\ntitle: Foo\n---\n\n# H\n");
let row = &lines[1];
assert_eq!(line_text(row), "title: Foo");
assert_eq!(row.spans.len(), 2, "got: {:?}", row.spans);
assert_eq!(row.spans[0].content.as_ref(), "title:");
assert_eq!(row.spans[0].style, theme.frontmatter_key);
assert_eq!(row.spans[1].content.as_ref(), " Foo");
assert_eq!(row.spans[1].style, theme.frontmatter_value);
}
#[test]
fn frontmatter_lines_without_a_key_render_whole() {
let lines = render("---\ntags:\n - https://example.com\n---\n\n# H\n");
let entry = &lines[2];
assert_eq!(line_text(entry), " - https://example.com");
assert_eq!(entry.spans.len(), 1, "got: {:?}", entry.spans);
}
fn warm_fence_languages(src: &str) {
for line in src.lines() {
let Some(info) = line.trim_start().strip_prefix("```") else {
continue;
};
if !info.trim().is_empty() {
edamame::markdown::highlight::warm_inline(Some(info.trim()));
}
}
}
fn render_highlighted(md: &str) -> Vec<ratatui::text::Line<'static>> {
warm_fence_languages(md);
let theme = Box::leak(Box::new(Theme::default()));
let blocks = parse(md);
Renderer::new(theme)
.with_syntax_highlighting(true)
.render(&blocks)
}
#[test]
fn fenced_blocks_highlight_by_language_only() {
let theme = Theme::default();
let md = "\
```rust
fn main() {}
```
```rust,ignore
fn other() {}
```
```frobnicate
fn main() {}
```
```
fn main() {}
```
";
let lines = render_highlighted(md);
let keyword_rows = lines
.iter()
.filter(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref() == "fn" && s.style.fg == theme.syntax_keyword.fg)
})
.count();
assert_eq!(
keyword_rows, 2,
"only the rust fences should be highlighted"
);
let all: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert_eq!(all.matches("fn main() {}").count(), 3);
assert!(all.contains("fn other() {}"));
}
#[test]
fn highlighting_off_is_indistinguishable_from_before_the_feature() {
let md = "```rust\nfn main() {}\nlet x = 1;\n```\n";
let plain = render(md);
assert!(plain.iter().all(|l| l
.spans
.iter()
.all(|s| s.style.fg == plain[1].spans[0].style.fg)));
assert!(plain[1].spans.len() == 1, "got {:?}", plain[1].spans);
}
#[test]
fn inline_math_renders_source_equivalently() {
let lines = render("Solve $x^2$ for x.\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(
text.contains("$x^2$"),
"inline math must render its delimited source, got: {text:?}"
);
}
#[test]
fn display_math_paragraph_renders_as_a_math_code_block_without_promotion() {
let lines = render("$$\nx^2\n$$\n");
let text: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(
text.contains("math") && text.contains("x^2"),
"unpromoted display math must render as a ` math ` code block, got: {text:?}"
);
assert!(
!text.contains("$$"),
"the styled block hides the `$$` delimiters (they reveal on cursor), got: {text:?}"
);
}
#[test]
fn display_math_opening_a_list_item_renders_as_a_math_code_block() {
let lines = render_at("- $$\n x^2\n y^2\n $$\n", 40);
let text: Vec<String> = lines.iter().map(line_text).collect();
let trimmed: Vec<&str> = text.iter().map(|l| l.trim_end()).collect();
assert_eq!(
trimmed[0], "•",
"the marker sits on a row of its own: {text:?}"
);
assert_eq!(
trimmed[1].trim(),
"math",
"then the ` math ` label: {text:?}"
);
assert_eq!(trimmed[2].trim(), "x^2", "{text:?}");
assert_eq!(trimmed[3].trim(), "y^2", "{text:?}");
assert!(
!text.iter().any(|l| l.contains("$$")),
"the `$$` delimiters reveal only on cursor: {text:?}"
);
}
#[test]
fn display_math_in_a_crlf_file_renders_as_a_math_code_block() {
for md in ["$$\r\nx^2\r\ny^2\r\n$$\r\n", "- $$\r\n x^2\r\n $$\r\n"] {
let lf: Vec<String> = render(&md.replace("\r\n", "\n"))
.iter()
.map(line_text)
.collect();
let crlf: Vec<String> = render(md).iter().map(line_text).collect();
assert_eq!(crlf, lf, "{md:?}");
assert!(crlf.iter().any(|l| l.trim() == "math"), "{crlf:?}");
}
}
fn line_cells(line: &ratatui::text::Line<'_>) -> usize {
str_cells(&line_text(line))
}
fn table_widths(lines: &[ratatui::text::Line<'static>]) -> Vec<usize> {
lines
.iter()
.filter(|l| {
matches!(
line_text(l).chars().next(),
Some('┌' | '│' | '├' | '┝' | '└')
)
})
.map(line_cells)
.collect()
}
#[test]
fn cjk_table_lines_all_span_the_border_width() {
let wide = "哈".repeat(10);
let lines = render_at(&format!("| {wide} |\n| --- |\n| 值 |\n"), 40);
let widths = table_widths(&lines);
assert!(
widths.len() >= 4,
"expected a bordered table, got {widths:?}"
);
assert!(
widths.iter().all(|w| *w == 24),
"every table line must be 24 columns wide (20 content + padding + borders), got {widths:?}"
);
}
#[test]
fn cjk_table_shrinks_into_a_narrow_viewport() {
let wide = "哈".repeat(10);
let lines = render_at(&format!("| {wide} |\n| --- |\n| 值 |\n"), 12);
let widths = table_widths(&lines);
assert!(
widths.iter().all(|w| *w == 12),
"every table line must fill the 12-column viewport without exceeding it, got {widths:?}"
);
}
fn assert_table_width(md: &str, viewport: usize, expected: usize) {
let widths = table_widths(&render_at(md, viewport));
assert!(
widths.len() >= 4,
"expected a bordered table, got {widths:?}"
);
assert!(
widths.iter().all(|w| *w == expected),
"every table line must be {expected} cells at viewport {viewport}, got {widths:?}"
);
}
const MIXED_TABLE: &str = "| Name | 説明 | Notes |\n|---|---|---|\n\
| apple | 赤い果物です | crisp and sweet |\n\
| 梨 | pear | 日本の梨はとても甘い |\n";
#[test]
fn mixed_cjk_table_fills_every_viewport_down_to_its_floor() {
for viewport in [24, 30, 40] {
assert_table_width(MIXED_TABLE, viewport, viewport);
}
assert_table_width(MIXED_TABLE, 16, 24);
}
#[test]
fn cjk_column_narrows_to_a_single_glyph() {
let md = format!("| {} |\n| --- |\n| 値 |\n", "哈".repeat(10));
assert_table_width(&md, 7, 7);
}
#[test]
fn multi_char_emoji_cells_stay_aligned() {
let heart = "| a \u{2764}\u{FE0F} b |\n| --- |\n| x |\n";
assert_table_width(heart, 80, 9);
let family = "| \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} fam |\n| --- |\n| x |\n";
assert_table_width(family, 80, 14);
}
#[test]
fn truncated_wide_cluster_keeps_the_row_full_width() {
let md = "| \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} |\n| --- |\n| x |\n\
<!-- tui-columns: [4] -->\n";
let lines = render_at(md, 80);
assert!(
lines.iter().any(|l| line_text(l).contains('…')),
"fixture must hit the truncation path"
);
assert_table_width(md, 80, 8);
}
fn cell_text() -> impl Strategy<Value = String> {
let piece = prop_oneof![
Just("word"),
Just("a"),
Just("日本語"),
Just("表"),
Just("\u{2764}\u{FE0F}"),
Just("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"),
Just("`code`"),
Just("é"),
];
prop::collection::vec((piece, any::<bool>()), 1..6).prop_map(|parts| {
parts
.into_iter()
.map(|(p, spaced)| {
if spaced {
format!("{p} ")
} else {
p.to_owned()
}
})
.collect::<String>()
.trim()
.to_owned()
})
}
proptest! {
#[test]
fn proptest_table_lines_share_one_width(
cells in prop::collection::vec(cell_text(), 4),
viewport in 8usize..60,
) {
let md = format!(
"| {} | {} |\n|---|---|\n| {} | {} |\n",
cells[0], cells[1], cells[2], cells[3]
);
let widths = table_widths(&render_at(&md, viewport));
prop_assert!(widths.windows(2).all(|w| w[0] == w[1]), "{md:?} @ {viewport}: {widths:?}");
}
}