use super::*;
use ratatui::style::Color;
fn render_plain(input: &str) -> Vec<String> {
render_markdown(input, None, None)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn plain_text() {
let lines = render_plain("Hello world");
assert_eq!(lines, vec!["Hello world"]);
}
#[test]
fn heading_prefix() {
let lines = render_plain("# Title\n\nBody");
assert_eq!(lines, vec!["# Title", "", "Body"]);
}
#[test]
fn heading_levels() {
let lines = render_plain("## Sub\n\n### Third");
assert_eq!(lines, vec!["## Sub", "", "### Third"]);
}
#[test]
fn emphasis_and_strong() {
let lines = render_markdown("*em* **strong**", None, None);
assert_eq!(lines.len(), 1);
let spans = &lines[0].spans;
assert!(spans.len() >= 2);
}
#[test]
fn inline_code() {
let lines = render_markdown("Use `print()`", None, None);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Use print()");
}
#[test]
fn code_block() {
let input = "```r\nx <- 1\ny <- 2\n```";
let lines = render_plain(input);
assert!(lines.contains(&"x <- 1".to_string()));
assert!(lines.contains(&"y <- 2".to_string()));
}
#[test]
fn unordered_list() {
let input = "- one\n- two\n- three";
let lines = render_plain(input);
assert_eq!(lines, vec!["- one", "- two", "- three"]);
}
#[test]
fn ordered_list() {
let input = "1. first\n2. second\n3. third";
let lines = render_plain(input);
assert_eq!(lines, vec!["1. first", "2. second", "3. third"]);
}
#[test]
fn nested_list() {
let input = "- outer\n - inner";
let lines = render_plain(input);
assert_eq!(lines, vec!["- outer", " - inner"]);
}
#[test]
fn blockquote() {
let input = "> quoted text";
let lines = render_plain(input);
assert_eq!(lines, vec!["> quoted text"]);
}
#[test]
fn simple_table() {
let input = "| A | B |\n|---|---|\n| 1 | 2 |";
let lines = render_plain(input);
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("A"));
assert!(lines[0].contains("B"));
assert!(lines[1].contains("-"));
assert!(lines[2].contains("1"));
assert!(lines[2].contains("2"));
}
#[test]
fn link_rendering() {
let input = "[click here](https://example.com)";
let lines = render_plain(input);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("click here"));
assert!(lines[0].contains("https://example.com"));
}
fn link_document(input: &str, width: usize) -> RenderedMarkdown {
render_markdown_document(input, None, Some(width), |_| LinkDisplay::LabelOnly)
}
fn fragment_text(document: &RenderedMarkdown, fragment: &LinkFragment) -> String {
let text: String = document.lines[fragment.line]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text[fragment.byte_range.clone()].to_owned()
}
#[test]
fn semantic_links_keep_distinct_identities_and_raw_destinations() {
let document = link_document(
"[one](x-r-help:base/%/%) and [**two**](x-r-help:base/%/%) plus [`code`](https://example.com?q=1#part)",
80,
);
assert_eq!(document.links.len(), 3);
assert_eq!(document.links[0].destination, "x-r-help:base/%/%");
assert_eq!(document.links[1].destination, document.links[0].destination);
assert_eq!(
document.links[2].destination,
"https://example.com?q=1#part"
);
for (index, label, range) in [(0, "one", 0..3), (1, "two", 8..11), (2, "code", 17..21)] {
let link = &document.links[index];
assert_eq!(link.label, label);
assert_eq!(link.text_ranges.len(), 1);
assert_eq!(link.text_ranges[0].range, range);
assert_eq!(link.fragments.len(), 1);
assert_eq!(link.fragments[0].columns, range);
assert_eq!(fragment_text(&document, &link.fragments[0]), label);
}
let spans = &document.lines[0].spans;
let bold = spans.iter().find(|span| span.content == "two").unwrap();
assert!(
bold.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
assert!(
bold.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED)
);
let code = spans.iter().find(|span| span.content == "code").unwrap();
assert!(
code.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED)
);
}
#[test]
fn link_columns_distinguish_unicode_width_from_bytes() {
let document = link_document("前🎉 [日本 **bold** `x` e\u{301}](opaque:target)", 80);
let link = &document.links[0];
assert_eq!(link.label, "日本 bold x e\u{301}");
assert_eq!(
link.fragments,
vec![LinkFragment {
line: 0,
byte_range: 8..25,
columns: 5..18,
}]
);
assert_eq!(fragment_text(&document, &link.fragments[0]), link.label);
}
#[test]
fn wrapped_link_ranges_keep_identity_across_widths() {
let input = "lead [alpha beta needle gamma](opaque:first) tail [end](opaque:second)";
for width in [0, 1, 6, 12, 80] {
let document = link_document(input, width);
assert_eq!(document.links.len(), 2);
let link = &document.links[0];
assert_eq!(link.label, "alpha beta needle gamma");
assert_eq!(link.destination, "opaque:first");
assert_eq!(link.text_ranges[0].range, 5..28);
assert_eq!(document.links[1].label, "end");
for fragment in &link.fragments {
let text = fragment_text(&document, fragment);
assert!(!text.is_empty());
assert!(link.label.contains(&text));
assert_eq!(fragment.columns.end - fragment.columns.start, text.len());
}
if width == 12 {
assert_eq!(
link.fragments,
vec![
LinkFragment {
line: 0,
byte_range: 5..10,
columns: 5..10
},
LinkFragment {
line: 1,
byte_range: 0..11,
columns: 0..11
},
LinkFragment {
line: 2,
byte_range: 0..5,
columns: 0..5
},
]
);
}
}
}
#[test]
fn link_ranges_exclude_list_and_blockquote_prefixes() {
let document = link_document("> - [alpha beta needle gamma](opaque:target)", 14);
let link = &document.links[0];
assert_eq!(link.label, "alpha beta needle gamma");
assert_eq!(link.text_ranges[0].range, 4..27);
assert!(link.fragments.len() > 1);
for fragment in &link.fragments {
assert!(fragment.columns.start >= 4);
assert!(link.label.contains(&fragment_text(&document, fragment)));
}
}
#[test]
fn table_links_keep_ranges_through_cell_breaks_and_wrapping() {
let input = "| 前 | Link |\n|---|---|\n| 日本語 | [alpha **beta**<br>needle gamma](opaque:target) |\n| 日本 | [other](opaque:target) |";
for width in [18, 26, 80] {
let document = link_document(input, width);
assert_eq!(document.links.len(), 2);
let link = &document.links[0];
assert_eq!(link.label, "alpha beta\nneedle gamma");
assert_eq!(link.text_ranges.len(), 2);
assert!(link.fragments.len() >= 2);
for fragment in &link.fragments {
let text = fragment_text(&document, fragment);
assert!(link.label.contains(&text));
assert!(!text.contains('|'));
}
if width == 80 {
assert_eq!(
link.fragments[0],
LinkFragment {
line: 2,
byte_range: 14..24,
columns: 11..21,
}
);
assert_eq!(
link.fragments[1],
LinkFragment {
line: 3,
byte_range: 11..23,
columns: 11..23,
}
);
}
assert_eq!(document.links[1].label, "other");
}
}
#[test]
fn link_identity_survives_explicit_line_breaks() {
let document = link_document("[alpha \nbeta<br>gamma](opaque:target)", 80);
let link = &document.links[0];
assert_eq!(link.label, "alpha\nbeta\ngamma");
assert_eq!(link.text_ranges.len(), 3);
assert_eq!(link.fragments.len(), 3);
let labels = link
.fragments
.iter()
.map(|f| fragment_text(&document, f))
.collect::<Vec<_>>();
assert_eq!(labels, ["alpha", "beta", "gamma"]);
}
#[test]
fn link_fallback_deduplicates_urls_and_excludes_destinations_from_ranges() {
for source in [
"<https://example.com>",
"[https://example.com](https://example.com)",
] {
assert_eq!(render_plain(source), ["https://example.com"]);
}
let document = render_markdown_with_text("[label](https://example.com)", None, None);
assert_eq!(document.links[0].label, "label");
assert_eq!(
fragment_text(&document, &document.links[0].fragments[0]),
"label"
);
assert_eq!(document.text[0].text, "label (https://example.com)");
assert_eq!(
render_plain("[mean](x-r-help:mean)"),
["mean (x-r-help:mean)"]
);
assert_eq!(
render_markdown("[label](https://example.com)", None, None),
document.lines,
);
}
#[test]
fn heading_links_and_empty_labels_have_valid_metadata() {
let document = link_document(
"# [Heading](opaque:h)\n\n[](opaque:empty)\n\n```\n[code](opaque:c)\n```",
1,
);
assert_eq!(document.links.len(), 2);
assert_eq!(document.links[0].fragments[0].columns, 2..9);
assert_eq!(document.links[1].destination, "opaque:empty");
assert!(document.links[1].fragments.is_empty());
}
#[test]
fn horizontal_rule() {
let input = "before\n\n---\n\nafter";
let lines = render_plain(input);
assert!(lines.contains(&"———".to_string()));
}
#[test]
fn table_with_br_tags() {
let input = "| Arg | Desc |\n|---|---|\n| x | first<br>second |";
let lines = render_plain(input);
assert!(lines.len() >= 4); assert!(lines[2].contains("first"));
assert!(lines[3].contains("second"));
}
#[test]
fn inline_html_br_outside_table() {
let input = "line one<br>line two";
let lines = render_plain(input);
assert_eq!(lines, vec!["line one", "line two"]);
}
#[test]
fn r_code_block_syntax_highlight() {
let input = "```r\nx <- 42\n```";
let lines = render_markdown(input, None, None);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(text.contains("x <- 42"));
let code_line = lines.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("<-")
});
assert!(code_line.is_some(), "Should have a line with <-");
let code_line = code_line.unwrap();
assert!(
code_line.spans.len() >= 3,
"R code should be tokenized into multiple spans, got {}",
code_line.spans.len()
);
let op_span = code_line
.spans
.iter()
.find(|s| s.content.as_ref().contains("<-"));
assert!(op_span.is_some(), "Should have an <- operator span");
assert_eq!(
op_span.unwrap().style.fg,
Some(Color::Yellow),
"Operator <- should be Yellow"
);
let num_span = code_line.spans.iter().find(|s| s.content.as_ref() == "42");
assert!(num_span.is_some(), "Should have a 42 number span");
assert_eq!(
num_span.unwrap().style.fg,
Some(Color::LightMagenta),
"Number 42 should be LightMagenta"
);
}
#[test]
fn non_r_code_block_uses_dim_style() {
let input = "```python\nprint('hello')\n```";
let lines = render_markdown(input, None, None);
let code_line = lines.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("print")
});
assert!(code_line.is_some());
let code_line = code_line.unwrap();
assert_eq!(
code_line.spans.len(),
1,
"Non-R code should be a single span"
);
assert!(
code_line.spans[0]
.style
.add_modifier
.contains(ratatui::style::Modifier::DIM),
"Non-R code should use DIM style"
);
}
#[test]
fn r_code_block_multiline() {
let input = "```r\nif (TRUE) {\n print(x)\n}\n```";
let lines = render_markdown(input, None, None);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(texts.iter().any(|t| t.contains("if")));
assert!(texts.iter().any(|t| t.contains("print")));
let if_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.as_ref() == "if"));
assert!(if_line.is_some());
let if_span = if_line
.unwrap()
.spans
.iter()
.find(|s| s.content.as_ref() == "if");
assert_eq!(
if_span.unwrap().style.fg,
Some(Color::LightBlue),
"Keyword 'if' should be LightBlue"
);
}
#[test]
fn r_code_block_with_comments() {
let input = "```r\n# A comment\nx <- 1\n```";
let lines = render_markdown(input, None, None);
let comment_line = lines.iter().find(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref().contains("# A comment"))
});
assert!(comment_line.is_some());
let comment_span = comment_line
.unwrap()
.spans
.iter()
.find(|s| s.content.as_ref().contains("# A comment"));
assert_eq!(
comment_span.unwrap().style.fg,
Some(Color::DarkGray),
"Comment should be DarkGray"
);
}
#[test]
fn untagged_code_block_with_default_r() {
let input = "```\nx <- 42\n```";
let lines_no_default = render_markdown(input, None, None);
let code_line = lines_no_default.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("<-")
});
assert!(code_line.is_some());
assert_eq!(code_line.unwrap().spans.len(), 1);
let lines_r = render_markdown(input, Some("r"), None);
let code_line = lines_r.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("<-")
});
assert!(code_line.is_some());
let code_line = code_line.unwrap();
assert!(
code_line.spans.len() >= 3,
"Default R should tokenize untagged code blocks, got {} spans",
code_line.spans.len()
);
let op_span = code_line
.spans
.iter()
.find(|s| s.content.as_ref().contains("<-"));
assert_eq!(op_span.unwrap().style.fg, Some(Color::Yellow));
}
#[test]
fn explicit_lang_overrides_default() {
let input = "```python\nprint('hello')\n```";
let lines = render_markdown(input, Some("r"), None);
let code_line = lines.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("print")
});
assert!(code_line.is_some());
assert_eq!(
code_line.unwrap().spans.len(),
1,
"Explicit python tag should not use R highlighting even with default_code_lang=r"
);
}
#[test]
fn empty_input() {
let lines = render_plain("");
assert!(lines.is_empty());
}
#[test]
fn paragraphs_separated_by_blank_line() {
let input = "First paragraph.\n\nSecond paragraph.";
let lines = render_plain(input);
assert_eq!(lines, vec!["First paragraph.", "", "Second paragraph."]);
}
fn render_wrapped(input: &str, width: usize) -> Vec<String> {
render_markdown(input, None, Some(width))
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn wrap_long_paragraph() {
let input = "Hello world, this is a long paragraph that should wrap.";
let lines = render_wrapped(input, 20);
assert!(lines.len() > 1, "Should wrap into multiple lines");
for line in &lines {
assert!(
unicode_width::UnicodeWidthStr::width(line.as_str()) <= 20,
"Line too wide: {:?} ({})",
line,
unicode_width::UnicodeWidthStr::width(line.as_str())
);
}
}
#[test]
fn wrap_code_block_not_wrapped() {
let input = "```\nthis is a very long code line that should not be wrapped at all ever\n```";
let lines = render_wrapped(input, 20);
let code_text: String = lines
.iter()
.find(|l| l.contains("this is"))
.cloned()
.unwrap_or_default();
assert!(code_text.len() > 20, "Code block should NOT be wrapped");
}
#[test]
fn wrap_table_cells() {
let input = "| Arg | Description |\n|---|---|\n| x | A very long description that should be wrapped within the cell |";
let lines = render_wrapped(input, 40);
assert!(
lines.len() > 3,
"Table cell should wrap: got {} lines: {:?}",
lines.len(),
lines
);
for line in &lines {
let w = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(w <= 40, "Table line too wide ({} > 40): {:?}", w, line);
}
}
#[test]
fn wrap_table_narrow_preserves_content() {
let input = "| Name | Value |\n|---|---|\n| foo | bar |";
let lines_wide = render_wrapped(input, 80);
let lines_narrow = render_wrapped(input, 30);
let all_text_wide: String = lines_wide.join("");
let all_text_narrow: String = lines_narrow.join("");
assert!(all_text_narrow.contains("foo"));
assert!(all_text_narrow.contains("bar"));
assert!(all_text_wide.contains("foo"));
assert!(all_text_wide.contains("bar"));
}
#[test]
fn wrap_prose_but_not_code_block() {
let input = "This is a long paragraph that will be wrapped at the given width.\n\n```\ncode_line_that_must_not_be_wrapped_ever()\n```";
let lines = render_wrapped(input, 30);
let code_start = lines
.iter()
.position(|l| l.contains("code_line"))
.expect("Should contain the code line");
assert!(
code_start > 2,
"Paragraph should have wrapped into multiple lines, got {}",
code_start
);
for (i, line) in lines.iter().enumerate() {
if i < code_start && !line.is_empty() {
let w = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(
w <= 30,
"Paragraph line should wrap (line {}: {} cols): {:?}",
i,
w,
line
);
}
}
let code_line = &lines[code_start];
assert!(
code_line.contains("code_line_that_must_not_be_wrapped_ever()"),
"Code block should remain intact"
);
}
#[test]
fn distribute_column_widths_empty() {
let empty: Vec<usize> = vec![];
assert_eq!(distribute_column_widths(&[], 10), empty);
}
#[test]
fn distribute_column_widths_single_column() {
assert_eq!(distribute_column_widths(&[20], 10), vec![10]);
}
#[test]
fn distribute_column_widths_all_fit() {
assert_eq!(distribute_column_widths(&[3, 3, 3], 12), vec![3, 3, 3]);
}
#[test]
fn distribute_column_widths_one_wide() {
let result = distribute_column_widths(&[2, 20, 3], 15);
assert_eq!(result[0], 2); assert_eq!(result[2], 3); assert_eq!(result[1], 10); assert_eq!(result.iter().sum::<usize>(), 15);
}
#[test]
fn distribute_column_widths_available_less_than_n() {
let result = distribute_column_widths(&[5, 10, 15], 2);
assert_eq!(result, vec![1, 1, 0]);
assert!(result.iter().sum::<usize>() <= 2);
}
#[test]
fn distribute_column_widths_available_equals_n() {
let result = distribute_column_widths(&[5, 10, 15], 3);
assert_eq!(result, vec![1, 1, 1]);
}