use std::collections::HashSet;
use std::ops::Range;
use crate::text_util::{display_width, wrap_spans, WrapPolicy};
use crate::theme::Theme;
use crate::types::{StyledSpan, StyledText};
#[derive(Debug, Clone)]
pub struct CodeBlockRange {
pub fence_open: usize,
pub fence_close: Option<usize>,
pub lang: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RenderedMarkdown {
pub lines: Vec<StyledText>,
pub line_text: Vec<String>,
pub line_scales: Vec<f32>,
pub code_blocks: Vec<CodeBlockRange>,
pub links: Vec<(usize, Range<usize>, String)>,
}
struct RawLink {
text: String,
url: String,
plain_offset: usize,
}
pub fn render_markdown_to_styled(input: &str, theme: &Theme) -> RenderedMarkdown {
let mut result = RenderedMarkdown::default();
let mut in_code_block = false;
let mut open_fence_idx: Option<usize> = None;
let mut open_fence_lang: Option<String> = None;
for raw_line in input.lines() {
let line_idx = result.lines.len();
if in_code_block {
if detect_fence(raw_line).is_some() {
result.code_blocks.push(CodeBlockRange {
fence_open: open_fence_idx.unwrap_or(line_idx),
fence_close: Some(line_idx),
lang: open_fence_lang.take(),
});
let (plain, styled) = render_fence_close();
result.lines.push(styled);
result.line_text.push(plain);
result.line_scales.push(1.0);
in_code_block = false;
open_fence_idx = None;
} else {
let (plain, styled) = render_code_content(raw_line, theme);
result.lines.push(styled);
result.line_text.push(plain);
result.line_scales.push(1.0);
}
} else if let Some(lang) = detect_fence(raw_line) {
let (plain, styled) = render_fence_open(lang.as_deref(), theme);
result.lines.push(styled);
result.line_text.push(plain);
result.line_scales.push(1.0);
in_code_block = true;
open_fence_idx = Some(line_idx);
open_fence_lang = lang;
} else {
let (plain, styled, scale, line_links) = render_regular_line(raw_line, theme);
for (start, end, url) in line_links {
result.links.push((line_idx, start..end, url));
}
result.lines.push(styled);
result.line_text.push(plain);
result.line_scales.push(scale);
}
}
if in_code_block {
if let Some(fence_open) = open_fence_idx {
result.code_blocks.push(CodeBlockRange {
fence_open,
fence_close: None,
lang: open_fence_lang,
});
}
}
result
}
pub fn render_markdown_to_styled_wrapped(
input: &str,
theme: &Theme,
width: usize,
) -> RenderedMarkdown {
if width == 0 {
return render_markdown_to_styled(input, theme);
}
let base = render_markdown_to_styled(input, theme);
if base.lines.is_empty() {
return base;
}
let mut no_wrap: HashSet<usize> = HashSet::new();
for cb in &base.code_blocks {
let close = cb
.fence_close
.unwrap_or_else(|| base.lines.len().saturating_sub(1));
for i in cb.fence_open..=close {
no_wrap.insert(i);
}
}
let mut out = RenderedMarkdown::default();
let mut orig_to_out_start: Vec<usize> = Vec::with_capacity(base.lines.len());
for i in 0..base.lines.len() {
let styled = &base.lines[i];
let plain = &base.line_text[i];
let scale = base.line_scales[i];
orig_to_out_start.push(out.lines.len());
let is_blockquote = plain.starts_with('\u{2502}');
if no_wrap.contains(&i) || display_width(plain) <= width {
out.lines.push(styled.clone());
out.line_text.push(plain.clone());
out.line_scales.push(scale);
} else {
let groups = wrap_spans(&styled.spans, width, WrapPolicy::Word);
for (seg_idx, group) in groups.into_iter().enumerate() {
let seg_plain: String = group.iter().map(|s| s.text.as_str()).collect();
let seg_styled = StyledText { spans: group };
let seg_scale = if seg_idx == 0 { scale } else { 1.0 };
if seg_idx > 0 && is_blockquote {
let bq_prefix = "\u{2502} ";
let prefix_span = StyledSpan {
text: bq_prefix.to_string(),
fg: Some(theme.link_fg),
bg: None,
bold: false,
italic: false,
underline: false,
};
let mut final_spans = vec![prefix_span];
final_spans.extend(seg_styled.spans);
out.lines.push(StyledText { spans: final_spans });
out.line_text.push(format!("{bq_prefix}{seg_plain}"));
} else {
out.lines.push(seg_styled);
out.line_text.push(seg_plain);
}
out.line_scales.push(seg_scale);
}
}
}
out.code_blocks = base
.code_blocks
.iter()
.map(|cb| CodeBlockRange {
fence_open: orig_to_out_start[cb.fence_open],
fence_close: cb.fence_close.map(|c| orig_to_out_start[c]),
lang: cb.lang.clone(),
})
.collect();
out
}
fn detect_fence(line: &str) -> Option<Option<String>> {
let trimmed = line.trim_start();
if let Some(after_ticks) = trimmed.strip_prefix("```") {
let after = after_ticks.trim();
if after.is_empty() {
Some(None)
} else {
Some(Some(after.to_string()))
}
} else {
None
}
}
const CODE_RAIL: &str = " \u{2503} ";
fn render_fence_open(lang: Option<&str>, theme: &Theme) -> (String, StyledText) {
match lang {
Some(lang) if !lang.is_empty() => {
let plain = format!(" {lang}");
let styled = StyledText {
spans: vec![
StyledSpan::plain(" "),
StyledSpan {
text: lang.to_string(),
fg: Some(theme.muted_fg),
bg: None,
bold: false,
italic: true,
underline: false,
},
],
};
(plain, styled)
}
_ => blank_line(),
}
}
fn render_fence_close() -> (String, StyledText) {
blank_line()
}
fn blank_line() -> (String, StyledText) {
(String::new(), StyledText { spans: Vec::new() })
}
fn render_code_content(line: &str, theme: &Theme) -> (String, StyledText) {
let plain = format!("{CODE_RAIL}{line}");
let spans = vec![
StyledSpan::plain(" "),
StyledSpan {
text: "\u{2503} ".to_string(),
fg: Some(theme.muted_fg),
bg: Some(theme.surface_bg),
bold: false,
italic: false,
underline: false,
},
StyledSpan {
text: line.to_string(),
fg: Some(theme.foreground),
bg: Some(theme.surface_bg),
bold: false,
italic: false,
underline: false,
},
];
(plain, StyledText { spans })
}
fn render_regular_line(
line: &str,
theme: &Theme,
) -> (String, StyledText, f32, Vec<(usize, usize, String)>) {
if let Some(content) = parse_blockquote(line) {
let (inner_spans, raw_links) = parse_inline(content, false, false, theme);
let inner_plain: String = inner_spans.iter().map(|s| s.text.as_str()).collect();
let prefix = "\u{2502} ";
let prefix_len = prefix.len(); let plain = format!("{prefix}{inner_plain}");
let links = resolve_links(&raw_links, prefix_len);
let bar_span = StyledSpan {
text: prefix.to_string(),
fg: Some(theme.link_fg),
bg: None,
bold: false,
italic: false,
underline: false,
};
let mut spans = vec![bar_span];
spans.extend(inner_spans);
return (plain, StyledText { spans }, 1.0, links);
}
if let Some(content) = parse_bullet_item(line) {
let (inner_spans, raw_links) = parse_inline(content, false, false, theme);
let inner_plain: String = inner_spans.iter().map(|s| s.text.as_str()).collect();
let prefix = " \u{2022} ";
let prefix_len = prefix.len(); let plain = format!("{prefix}{inner_plain}");
let links = resolve_links(&raw_links, prefix_len);
let indent_span = StyledSpan::plain(" ");
let bullet_span = StyledSpan {
text: "\u{2022} ".to_string(),
fg: Some(theme.accent_fg),
bg: None,
bold: false,
italic: false,
underline: false,
};
let mut spans = vec![indent_span, bullet_span];
spans.extend(inner_spans);
return (plain, StyledText { spans }, 1.0, links);
}
if let Some((num, content)) = parse_ordered_item(line) {
let (inner_spans, raw_links) = parse_inline(content, false, false, theme);
let inner_plain: String = inner_spans.iter().map(|s| s.text.as_str()).collect();
let marker = format!("{num}. ");
let prefix = format!(" {marker}");
let prefix_len = prefix.len();
let plain = format!("{prefix}{inner_plain}");
let links = resolve_links(&raw_links, prefix_len);
let indent_span = StyledSpan::plain(" ");
let marker_span = StyledSpan {
text: marker,
fg: Some(theme.accent_fg),
bg: None,
bold: false,
italic: false,
underline: false,
};
let mut spans = vec![indent_span, marker_span];
spans.extend(inner_spans);
return (plain, StyledText { spans }, 1.0, links);
}
let (heading_level, content) = parse_heading_prefix(line);
let scale = heading_scale(heading_level);
let (spans, raw_links) = parse_inline(content, heading_level > 0, false, theme);
let plain: String = spans.iter().map(|s| s.text.as_str()).collect();
let links = resolve_links(&raw_links, 0);
(plain, StyledText { spans }, scale, links)
}
fn parse_blockquote(line: &str) -> Option<&str> {
if let Some(rest) = line.strip_prefix("> ") {
Some(rest)
} else if line.starts_with('>') && !line.starts_with(">>") {
Some(&line[1..])
} else {
None
}
}
fn parse_bullet_item(line: &str) -> Option<&str> {
if let Some(rest) = line.strip_prefix("- ") {
Some(rest)
} else if let Some(rest) = line.strip_prefix("* ") {
Some(rest)
} else {
None
}
}
fn parse_ordered_item(line: &str) -> Option<(u32, &str)> {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == 0 {
return None;
}
if i >= bytes.len() || bytes[i] != b'.' {
return None;
}
if i + 1 >= bytes.len() || bytes[i + 1] != b' ' {
return None;
}
let num: u32 = line[..i].parse().ok()?;
Some((num, &line[i + 2..]))
}
fn parse_heading_prefix(line: &str) -> (u8, &str) {
if let Some(rest) = line.strip_prefix("### ") {
(3, rest)
} else if let Some(rest) = line.strip_prefix("## ") {
(2, rest)
} else if let Some(rest) = line.strip_prefix("# ") {
(1, rest)
} else {
(0, line)
}
}
fn heading_scale(level: u8) -> f32 {
match level {
1 => 2.0,
2 => 1.5,
3 => 1.2,
_ => 1.0,
}
}
fn resolve_links(raw_links: &[RawLink], prefix_len: usize) -> Vec<(usize, usize, String)> {
raw_links
.iter()
.map(|link| {
let start = prefix_len + link.plain_offset;
let end = start + link.text.len();
(start, end, link.url.clone())
})
.collect()
}
fn parse_inline(
text: &str,
bold: bool,
italic: bool,
theme: &Theme,
) -> (Vec<StyledSpan>, Vec<RawLink>) {
let mut spans: Vec<StyledSpan> = Vec::new();
let mut links: Vec<RawLink> = Vec::new();
let bytes = text.as_bytes();
let len = bytes.len();
let mut pos = 0usize;
let mut plain_start = 0usize;
let mut plain_offset = 0usize;
while pos < len {
let b = bytes[pos];
if b == b'`' {
let after = pos + 1;
if let Some(close_rel) = text[after..].find('`') {
if plain_start < pos {
let chunk = &text[plain_start..pos];
plain_offset += chunk.len();
spans.push(make_span(chunk, bold, italic, None));
}
let code_text = &text[after..after + close_rel];
spans.push(StyledSpan {
text: code_text.to_string(),
fg: Some(theme.accent_fg),
bg: None,
bold,
italic: false,
underline: false,
});
plain_offset += code_text.len();
pos = after + close_rel + 1;
plain_start = pos;
continue;
}
pos += 1;
continue;
}
if b == b'[' {
if let Some((link_text, url, after)) = parse_link(text, pos) {
if plain_start < pos {
let chunk = &text[plain_start..pos];
plain_offset += chunk.len();
spans.push(make_span(chunk, bold, italic, None));
}
let link_start_in_plain = plain_offset;
spans.push(StyledSpan {
text: link_text.to_string(),
fg: Some(theme.link_fg),
bg: None,
bold: false,
italic: false,
underline: true,
});
plain_offset += link_text.len();
links.push(RawLink {
text: link_text.to_string(),
url: url.to_string(),
plain_offset: link_start_in_plain,
});
pos = after;
plain_start = pos;
continue;
}
pos += 1;
continue;
}
if (b == b'*' || b == b'_') && pos + 1 < len && bytes[pos + 1] == b {
let run_end = pos + 2;
if can_open(text, pos, run_end, b) {
if let Some(close) = find_emphasis_close(text, run_end, b, 2) {
if plain_start < pos {
let chunk = &text[plain_start..pos];
plain_offset += chunk.len();
spans.push(make_span(chunk, bold, italic, None));
}
let inner = &text[run_end..close];
let offset_before_inner = plain_offset;
let (inner_spans, mut inner_links) = parse_inline(inner, true, italic, theme);
for lnk in &mut inner_links {
lnk.plain_offset += offset_before_inner;
}
plain_offset += inner_spans.iter().map(|s| s.text.len()).sum::<usize>();
spans.extend(inner_spans);
links.extend(inner_links);
pos = close + 2;
plain_start = pos;
continue;
}
}
pos += 2;
continue;
}
if b == b'*' || b == b'_' {
let run_end = pos + 1;
if can_open(text, pos, run_end, b) {
if let Some(close) = find_emphasis_close(text, run_end, b, 1) {
if plain_start < pos {
let chunk = &text[plain_start..pos];
plain_offset += chunk.len();
spans.push(make_span(chunk, bold, italic, None));
}
let inner = &text[run_end..close];
let offset_before_inner = plain_offset;
let (inner_spans, mut inner_links) = parse_inline(inner, bold, true, theme);
for lnk in &mut inner_links {
lnk.plain_offset += offset_before_inner;
}
plain_offset += inner_spans.iter().map(|s| s.text.len()).sum::<usize>();
spans.extend(inner_spans);
links.extend(inner_links);
pos = close + 1;
plain_start = pos;
continue;
}
}
pos += 1;
continue;
}
pos += 1;
}
if plain_start < len {
spans.push(make_span(&text[plain_start..], bold, italic, None));
}
(spans, links)
}
fn parse_link(text: &str, start: usize) -> Option<(&str, &str, usize)> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = start + 1;
let mut depth = 1i32;
while i < len {
match bytes[i] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
break;
}
}
b'\\' if i + 1 < len => {
i += 1;
}
_ => {}
}
i += 1;
}
if depth != 0 || i >= len {
return None;
}
let close_bracket = i; let link_text = &text[start + 1..close_bracket];
let after_bracket = close_bracket + 1;
if after_bracket >= len || bytes[after_bracket] != b'(' {
return None;
}
let url_start = after_bracket + 1;
let url_end = text[url_start..].find(')')?;
let url = &text[url_start..url_start + url_end];
let after = url_start + url_end + 1;
Some((link_text, url, after))
}
fn char_before(text: &str, pos: usize) -> Option<char> {
text[..pos].chars().next_back()
}
fn char_at(text: &str, pos: usize) -> Option<char> {
text[pos..].chars().next()
}
fn is_ws_boundary(c: Option<char>) -> bool {
match c {
None => true,
Some(ch) => ch.is_whitespace(),
}
}
fn is_punct(c: Option<char>) -> bool {
matches!(c, Some(ch) if ch.is_ascii_punctuation())
}
fn is_left_flanking(text: &str, start: usize, end: usize) -> bool {
let after = char_at(text, end);
let before = char_before(text, start);
if is_ws_boundary(after) {
return false;
}
!is_punct(after) || is_ws_boundary(before) || is_punct(before)
}
fn is_right_flanking(text: &str, start: usize, end: usize) -> bool {
let before = char_before(text, start);
let after = char_at(text, end);
if is_ws_boundary(before) {
return false;
}
!is_punct(before) || is_ws_boundary(after) || is_punct(after)
}
fn can_open(text: &str, start: usize, end: usize, delim: u8) -> bool {
let left = is_left_flanking(text, start, end);
if delim == b'*' {
left
} else {
left && (!is_right_flanking(text, start, end) || is_punct(char_before(text, start)))
}
}
fn can_close(text: &str, start: usize, end: usize, delim: u8) -> bool {
let right = is_right_flanking(text, start, end);
if delim == b'*' {
right
} else {
right && (!is_left_flanking(text, start, end) || is_punct(char_at(text, end)))
}
}
fn find_emphasis_close(text: &str, from: usize, delim: u8, run: usize) -> Option<usize> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = from;
while i < len {
if bytes[i] == delim {
let mut j = i;
while j < len && bytes[j] == delim {
j += 1;
}
let this_run = j - i;
if this_run >= run && i > from && can_close(text, i, j, delim) {
return Some(j - run);
}
i = j;
} else {
i += 1;
}
}
None
}
fn make_span(text: &str, bold: bool, italic: bool, fg: Option<crate::types::Color>) -> StyledSpan {
StyledSpan {
text: text.to_string(),
fg,
bg: None,
bold,
italic,
underline: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Theme;
#[test]
fn output_vectors_are_length_aligned() {
let inputs = &[
"",
"plain text",
"# Heading",
"line one\nline two\nline three",
"**bold** and *italic* and `code`",
"# H1\n## H2\n### H3\nbody",
"- bullet item",
"1. numbered item",
"> blockquote",
"```rust\nfn main() {}\n```",
"see [link](http://example.com) here",
"```\nunclosed",
];
let theme = Theme::default();
for input in inputs {
let r = render_markdown_to_styled(input, &theme);
assert_eq!(
r.lines.len(),
r.line_text.len(),
"line_text length mismatch for input {input:?}"
);
assert_eq!(
r.lines.len(),
r.line_scales.len(),
"line_scales length mismatch for input {input:?}"
);
}
}
#[test]
fn h1_produces_scale_2_and_bold_span() {
let theme = Theme::default();
let r = render_markdown_to_styled("# Hello", &theme);
assert_eq!(r.lines.len(), 1);
assert!(
(r.line_scales[0] - 2.0).abs() < f32::EPSILON,
"H1 scale should be 2.0"
);
assert!(
r.lines[0].spans.iter().any(|s| s.bold),
"H1 should produce at least one bold span"
);
assert_eq!(r.line_text[0], "Hello");
}
#[test]
fn h2_produces_scale_1_5_and_bold_span() {
let theme = Theme::default();
let r = render_markdown_to_styled("## World", &theme);
assert!(
(r.line_scales[0] - 1.5).abs() < f32::EPSILON,
"H2 scale should be 1.5"
);
assert!(r.lines[0].spans.iter().any(|s| s.bold));
}
#[test]
fn h3_produces_scale_1_2_and_bold_span() {
let theme = Theme::default();
let r = render_markdown_to_styled("### Section", &theme);
assert!(
(r.line_scales[0] - 1.2).abs() < f32::EPSILON,
"H3 scale should be 1.2"
);
assert!(r.lines[0].spans.iter().any(|s| s.bold));
}
#[test]
fn body_line_has_scale_1_0() {
let theme = Theme::default();
let r = render_markdown_to_styled("Just text", &theme);
assert!(
(r.line_scales[0] - 1.0).abs() < f32::EPSILON,
"body scale should be 1.0"
);
}
#[test]
fn heading_plain_text_is_content_without_hashes() {
let theme = Theme::default();
let r = render_markdown_to_styled("# My Heading", &theme);
assert_eq!(r.line_text[0], "My Heading");
}
#[test]
fn double_asterisk_bold() {
let theme = Theme::default();
let r = render_markdown_to_styled("**bold**", &theme);
assert_eq!(r.lines.len(), 1);
let bold_spans: Vec<_> = r.lines[0].spans.iter().filter(|s| s.bold).collect();
assert!(!bold_spans.is_empty(), "expected at least one bold span");
assert!(bold_spans.iter().any(|s| s.text == "bold"));
}
#[test]
fn double_underscore_bold() {
let theme = Theme::default();
let r = render_markdown_to_styled("__bold__", &theme);
assert!(r.lines[0].spans.iter().any(|s| s.bold && s.text == "bold"));
}
#[test]
fn single_asterisk_italic() {
let theme = Theme::default();
let r = render_markdown_to_styled("*italic*", &theme);
assert_eq!(r.lines.len(), 1);
let italic_spans: Vec<_> = r.lines[0].spans.iter().filter(|s| s.italic).collect();
assert!(
!italic_spans.is_empty(),
"expected at least one italic span"
);
assert!(italic_spans.iter().any(|s| s.text == "italic"));
}
#[test]
fn single_underscore_italic() {
let theme = Theme::default();
let r = render_markdown_to_styled("_italic_", &theme);
assert!(r.lines[0]
.spans
.iter()
.any(|s| s.italic && s.text == "italic"));
}
#[test]
fn backtick_code_span_uses_accent_fg() {
let theme = Theme::default();
let r = render_markdown_to_styled("`code`", &theme);
assert_eq!(r.lines.len(), 1);
let code_spans: Vec<_> = r.lines[0]
.spans
.iter()
.filter(|s| s.fg == Some(theme.accent_fg))
.collect();
assert!(!code_spans.is_empty(), "expected a code-colored span");
assert!(code_spans.iter().any(|s| s.text == "code"));
}
#[test]
fn backtick_code_is_not_bold_or_italic() {
let theme = Theme::default();
let r = render_markdown_to_styled("`code`", &theme);
let code_spans: Vec<_> = r.lines[0]
.spans
.iter()
.filter(|s| s.fg == Some(theme.accent_fg))
.collect();
for s in &code_spans {
assert!(!s.bold, "inline code should not be bold");
assert!(!s.italic, "inline code should not be italic");
}
}
#[test]
fn mixed_inline_styles_split_into_correct_spans() {
let theme = Theme::default();
let r = render_markdown_to_styled("**bold** and *italic* and `code`", &theme);
assert_eq!(r.lines.len(), 1);
let spans = &r.lines[0].spans;
assert!(
spans.iter().any(|s| s.bold && s.text.contains("bold")),
"missing bold span; spans: {spans:?}"
);
assert!(
spans.iter().any(|s| s.italic && s.text.contains("italic")),
"missing italic span; spans: {spans:?}"
);
assert!(
spans
.iter()
.any(|s| s.fg == Some(theme.accent_fg) && s.text.contains("code")),
"missing code span; spans: {spans:?}"
);
assert!(
spans.iter().any(|s| s.text.contains(" and ")),
"expected plain ' and ' separator; spans: {spans:?}"
);
assert_eq!(r.line_text[0], "bold and italic and code");
}
#[test]
fn bold_italic_combined_in_heading() {
let theme = Theme::default();
let r = render_markdown_to_styled("# *emphasized heading*", &theme);
assert!(
r.lines[0].spans.iter().any(|s| s.bold && s.italic),
"expected bold+italic span inside heading; spans: {:?}",
r.lines[0].spans
);
}
#[test]
fn multi_line_all_three_heading_levels() {
let theme = Theme::default();
let r = render_markdown_to_styled("# H1\n## H2\n### H3\nbody", &theme);
assert_eq!(r.lines.len(), 4);
assert!((r.line_scales[0] - 2.0).abs() < f32::EPSILON);
assert!((r.line_scales[1] - 1.5).abs() < f32::EPSILON);
assert!((r.line_scales[2] - 1.2).abs() < f32::EPSILON);
assert!((r.line_scales[3] - 1.0).abs() < f32::EPSILON);
}
#[test]
fn empty_input_produces_empty_vectors() {
let theme = Theme::default();
let r = render_markdown_to_styled("", &theme);
assert!(r.lines.is_empty());
assert!(r.line_text.is_empty());
assert!(r.line_scales.is_empty());
}
#[test]
fn plain_text_passes_through_unchanged() {
let theme = Theme::default();
let r = render_markdown_to_styled("Hello, world!", &theme);
assert_eq!(r.line_text[0], "Hello, world!");
assert_eq!(r.lines[0].spans.len(), 1);
assert!(!r.lines[0].spans[0].bold);
assert!(!r.lines[0].spans[0].italic);
assert!(r.lines[0].spans[0].fg.is_none());
}
#[test]
fn unmatched_delimiter_treated_as_plain_text() {
let theme = Theme::default();
let r = render_markdown_to_styled("price is $5*2", &theme);
assert_eq!(r.line_text[0], "price is $5*2");
assert!(r.lines[0].spans.iter().all(|s| !s.italic));
}
#[test]
fn intraword_underscores_do_not_italicise() {
let theme = Theme::default();
let r = render_markdown_to_styled("the foo_bar and baz_qux funcs", &theme);
assert!(
r.lines[0].spans.iter().all(|s| !s.italic),
"intraword underscores must not italicise; spans: {:?}",
r.lines[0].spans
);
assert_eq!(r.line_text[0], "the foo_bar and baz_qux funcs");
}
#[test]
fn whitespace_flanked_asterisks_do_not_italicise() {
let theme = Theme::default();
let r = render_markdown_to_styled("a * b * c", &theme);
assert!(
r.lines[0].spans.iter().all(|s| !s.italic),
"whitespace-flanked asterisks must not italicise; spans: {:?}",
r.lines[0].spans
);
assert_eq!(r.line_text[0], "a * b * c");
}
#[test]
fn single_snake_case_underscore_is_literal() {
let theme = Theme::default();
let r = render_markdown_to_styled("render_markdown_to_styled does work", &theme);
assert!(r.lines[0].spans.iter().all(|s| !s.italic));
assert_eq!(r.line_text[0], "render_markdown_to_styled does work");
}
#[test]
fn intraword_double_underscore_does_not_bold() {
let theme = Theme::default();
let r = render_markdown_to_styled("foo__bar__baz", &theme);
assert!(
r.lines[0].spans.iter().all(|s| !s.bold),
"intraword __ must not bold; spans: {:?}",
r.lines[0].spans
);
assert_eq!(r.line_text[0], "foo__bar__baz");
}
#[test]
fn well_formed_emphasis_still_works_after_flanking_guard() {
let theme = Theme::default();
let r = render_markdown_to_styled("use *italic* and _also_ and **bold**", &theme);
let spans = &r.lines[0].spans;
assert!(spans.iter().any(|s| s.italic && s.text == "italic"));
assert!(spans.iter().any(|s| s.italic && s.text == "also"));
assert!(spans.iter().any(|s| s.bold && s.text == "bold"));
}
#[test]
fn inline_code_in_heading_stays_bold() {
let theme = Theme::default();
let r = render_markdown_to_styled("# the `fn` keyword", &theme);
let code_span = r.lines[0]
.spans
.iter()
.find(|s| s.fg == Some(theme.accent_fg))
.expect("expected an inline-code span in the heading");
assert!(code_span.bold, "inline code in a heading should be bold");
assert!(!code_span.italic);
}
#[test]
fn heading_with_no_content_is_still_a_heading() {
let theme = Theme::default();
let r = render_markdown_to_styled("# ", &theme);
assert!((r.line_scales[0] - 2.0).abs() < f32::EPSILON);
}
#[test]
fn hash_without_space_is_not_a_heading() {
let theme = Theme::default();
let r = render_markdown_to_styled("#title", &theme);
assert!(
(r.line_scales[0] - 1.0).abs() < f32::EPSILON,
"should be body, not heading"
);
assert_eq!(r.line_text[0], "#title");
}
#[test]
fn dash_bullet_emits_bullet_glyph_and_accent() {
let theme = Theme::default();
let r = render_markdown_to_styled("- hello", &theme);
assert_eq!(r.lines.len(), 1);
assert!(
r.line_text[0].contains('\u{2022}'),
"plain text must contain bullet glyph; got: {:?}",
r.line_text[0]
);
assert!(
r.line_text[0].contains("hello"),
"plain text must include the item content"
);
assert!(
r.lines[0]
.spans
.iter()
.any(|s| s.fg == Some(theme.accent_fg) && s.text.contains('\u{2022}')),
"expected an accent-coloured bullet span; spans: {:?}",
r.lines[0].spans
);
}
#[test]
fn asterisk_bullet_emits_bullet_glyph() {
let theme = Theme::default();
let r = render_markdown_to_styled("* world", &theme);
assert!(r.line_text[0].contains('\u{2022}'));
assert!(r.line_text[0].contains("world"));
}
#[test]
fn bullet_item_inline_markup_is_parsed() {
let theme = Theme::default();
let r = render_markdown_to_styled("- **bold** item", &theme);
assert!(
r.lines[0].spans.iter().any(|s| s.bold && s.text == "bold"),
"inline bold inside bullet must be parsed; spans: {:?}",
r.lines[0].spans
);
assert_eq!(r.line_text[0], " \u{2022} bold item");
}
#[test]
fn bullet_scale_is_1_0() {
let theme = Theme::default();
let r = render_markdown_to_styled("- item", &theme);
assert!((r.line_scales[0] - 1.0).abs() < f32::EPSILON);
}
#[test]
fn numbered_list_emits_number_and_accent() {
let theme = Theme::default();
let r = render_markdown_to_styled("1. first", &theme);
assert_eq!(r.lines.len(), 1);
assert!(
r.line_text[0].contains("1."),
"plain text must contain the marker"
);
assert!(r.line_text[0].contains("first"));
assert!(
r.lines[0]
.spans
.iter()
.any(|s| s.fg == Some(theme.accent_fg) && s.text.contains("1.")),
"expected accent-coloured marker span; spans: {:?}",
r.lines[0].spans
);
}
#[test]
fn numbered_list_multi_digit() {
let theme = Theme::default();
let r = render_markdown_to_styled("10. tenth", &theme);
assert!(r.line_text[0].contains("10."));
assert!(r.line_text[0].contains("tenth"));
}
#[test]
fn numbered_list_inline_markup_is_parsed() {
let theme = Theme::default();
let r = render_markdown_to_styled("2. *italic* content", &theme);
assert!(
r.lines[0]
.spans
.iter()
.any(|s| s.italic && s.text == "italic"),
"inline italic inside numbered item must be parsed; spans: {:?}",
r.lines[0].spans
);
}
#[test]
fn numbered_scale_is_1_0() {
let theme = Theme::default();
let r = render_markdown_to_styled("3. item", &theme);
assert!((r.line_scales[0] - 1.0).abs() < f32::EPSILON);
}
#[test]
fn code_block_three_lines_produce_three_entries() {
let theme = Theme::default();
let r = render_markdown_to_styled("```rust\nfn main() {}\n```", &theme);
assert_eq!(r.lines.len(), 3, "fence-open, content, fence-close");
for scale in &r.line_scales {
assert!((scale - 1.0).abs() < f32::EPSILON);
}
}
#[test]
fn code_block_open_fence_shows_language_header() {
let theme = Theme::default();
let r = render_markdown_to_styled("```rust\ncode\n```", &theme);
assert_eq!(r.line_text[0], " rust");
assert!(
r.lines[0]
.spans
.iter()
.any(|s| s.fg == Some(theme.muted_fg) && s.text == "rust"),
"opening fence must show the language in muted_fg; spans: {:?}",
r.lines[0].spans
);
}
#[test]
fn code_block_open_fence_without_lang_is_blank() {
let theme = Theme::default();
let r = render_markdown_to_styled("```\ncode\n```", &theme);
assert_eq!(r.line_text[0], "");
assert!(r.lines[0].spans.is_empty());
}
#[test]
fn code_block_close_fence_is_blank() {
let theme = Theme::default();
let r = render_markdown_to_styled("```rust\ncode\n```", &theme);
assert_eq!(r.line_text[2], "");
assert!(r.lines[2].spans.is_empty());
}
#[test]
fn code_block_never_emits_raw_backtick_fences() {
let theme = Theme::default();
let r = render_markdown_to_styled("```rust\nlet x = 1;\n```", &theme);
assert!(
r.line_text.iter().all(|l| !l.contains("```")),
"raw ``` fences must not leak into rendered text; got: {:?}",
r.line_text
);
}
#[test]
fn code_block_content_has_code_rail() {
let theme = Theme::default();
let r = render_markdown_to_styled("```\nlet x = 1;\n```", &theme);
assert_eq!(r.line_text[1], " \u{2503} let x = 1;");
assert!(
r.lines[1]
.spans
.iter()
.any(|s| s.fg == Some(theme.muted_fg) && s.text.contains('\u{2503}')),
"expected a muted_fg rail span; spans: {:?}",
r.lines[1].spans
);
}
#[test]
fn code_block_content_line_uses_foreground() {
let theme = Theme::default();
let r = render_markdown_to_styled("```\ncontent\n```", &theme);
let content = r.lines[1]
.spans
.iter()
.find(|s| s.text == "content")
.expect("expected a span carrying the code content");
assert_eq!(
content.fg,
Some(theme.foreground),
"code content must use foreground fg"
);
}
#[test]
fn code_block_content_not_emphasis_parsed() {
let theme = Theme::default();
let r = render_markdown_to_styled("```\n**not bold**\n```", &theme);
assert!(
r.lines[1].spans.iter().all(|s| !s.bold),
"content inside code block must not be emphasis-parsed"
);
assert_eq!(r.line_text[1], " \u{2503} **not bold**");
}
#[test]
fn code_block_range_side_channel_populated() {
let theme = Theme::default();
let r = render_markdown_to_styled("```rust\nfn f() {}\n```", &theme);
assert_eq!(r.code_blocks.len(), 1);
let cb = &r.code_blocks[0];
assert_eq!(cb.fence_open, 0);
assert_eq!(cb.fence_close, Some(2));
assert_eq!(cb.lang.as_deref(), Some("rust"));
}
#[test]
fn code_block_no_lang_tag() {
let theme = Theme::default();
let r = render_markdown_to_styled("```\ncode\n```", &theme);
assert_eq!(r.code_blocks.len(), 1);
assert!(r.code_blocks[0].lang.is_none());
}
#[test]
fn unclosed_code_block_recorded_with_no_close() {
let theme = Theme::default();
let r = render_markdown_to_styled("```\nline1\nline2", &theme);
assert_eq!(r.lines.len(), 3);
assert_eq!(r.code_blocks.len(), 1);
assert_eq!(r.code_blocks[0].fence_open, 0);
assert!(r.code_blocks[0].fence_close.is_none());
}
#[test]
fn link_emits_underlined_link_fg_span() {
let theme = Theme::default();
let r = render_markdown_to_styled("[click here](http://example.com)", &theme);
assert_eq!(r.lines.len(), 1);
let link_span = r.lines[0]
.spans
.iter()
.find(|s| s.text == "click here")
.expect("expected a span with the link text");
assert!(link_span.underline, "link span must be underlined");
assert_eq!(
link_span.fg,
Some(theme.link_fg),
"link span must use link_fg"
);
}
#[test]
fn link_plain_text_is_link_text_not_url() {
let theme = Theme::default();
let r = render_markdown_to_styled("[label](http://example.com)", &theme);
assert_eq!(r.line_text[0], "label");
}
#[test]
fn link_side_channel_populated() {
let theme = Theme::default();
let r = render_markdown_to_styled("[label](http://example.com)", &theme);
assert_eq!(r.links.len(), 1);
let (line_idx, ref range, ref url) = r.links[0];
assert_eq!(line_idx, 0);
assert_eq!(&r.line_text[0][range.clone()], "label");
assert_eq!(url, "http://example.com");
}
#[test]
fn link_inside_body_text_mixed() {
let theme = Theme::default();
let r = render_markdown_to_styled("see [docs](http://docs.example.com) for more", &theme);
assert_eq!(r.line_text[0], "see docs for more");
assert_eq!(r.links.len(), 1);
let (_, ref range, _) = r.links[0];
assert_eq!(&r.line_text[0][range.clone()], "docs");
}
#[test]
fn link_side_channel_byte_range_matches_plain_text() {
let theme = Theme::default();
let r = render_markdown_to_styled("[alpha](http://a.com) and [beta](http://b.com)", &theme);
assert_eq!(r.links.len(), 2);
let (_, ref r0, ref u0) = r.links[0];
let (_, ref r1, ref u1) = r.links[1];
assert_eq!(&r.line_text[0][r0.clone()], "alpha");
assert_eq!(u0, "http://a.com");
assert_eq!(&r.line_text[0][r1.clone()], "beta");
assert_eq!(u1, "http://b.com");
}
#[test]
fn link_range_correct_when_text_appears_before_link() {
let theme = Theme::default();
let r = render_markdown_to_styled("see foo and [foo](http://example.com)", &theme);
assert_eq!(r.links.len(), 1);
let (_, ref range, ref url) = r.links[0];
assert_eq!(
&r.line_text[0][range.clone()],
"foo",
"byte range must point to the link occurrence, not the plain-text occurrence; \
line_text={:?}, range={range:?}",
r.line_text[0]
);
let start = range.start;
assert_eq!(
start, 12,
"link must start at byte 12 (after 'see foo and '), got {start}"
);
assert_eq!(url, "http://example.com");
}
#[test]
fn link_range_correct_inside_bold_when_text_appears_before() {
let theme = Theme::default();
let r = render_markdown_to_styled("alpha **[alpha](url)**", &theme);
assert_eq!(r.links.len(), 1);
let (_, ref range, _) = r.links[0];
assert_eq!(
&r.line_text[0][range.clone()],
"alpha",
"byte range must target the bold-link occurrence; line_text={:?}, range={range:?}",
r.line_text[0]
);
assert_eq!(range.start, 6, "link must start after leading 'alpha '");
}
#[test]
fn wrapped_empty_input_is_no_op() {
let theme = Theme::default();
let r = render_markdown_to_styled_wrapped("", &theme, 40);
assert!(r.lines.is_empty(), "empty input must produce no lines");
assert!(r.line_text.is_empty());
assert!(r.line_scales.is_empty());
}
#[test]
fn wrapped_short_line_unchanged() {
let theme = Theme::default();
let base = render_markdown_to_styled("Hello", &theme);
let wrapped = render_markdown_to_styled_wrapped("Hello", &theme, 40);
assert_eq!(wrapped.lines.len(), 1);
assert_eq!(wrapped.line_text[0], base.line_text[0]);
}
#[test]
fn wrapped_long_paragraph_produces_multiple_rows() {
let theme = Theme::default();
let input =
"This is a long sentence that should be wrapped at a relatively small width here.";
let r = render_markdown_to_styled_wrapped(input, &theme, 20);
assert!(
r.lines.len() > 1,
"long line should produce more than one visual row"
);
assert_eq!(
r.lines.len(),
r.line_text.len(),
"line_text length mismatch"
);
assert_eq!(
r.lines.len(),
r.line_scales.len(),
"line_scales length mismatch"
);
for (i, lt) in r.line_text.iter().enumerate() {
assert!(
lt.chars().count() <= 20,
"wrapped row {i} exceeds width 20: {:?}",
lt
);
}
let joined = r.line_text.join(" ");
assert!(
joined.contains("long sentence"),
"original words must survive wrapping"
);
}
#[test]
fn wrapped_overlong_word_hard_splits() {
let theme = Theme::default();
let input = "abcdefghijklmnopqrst";
let r = render_markdown_to_styled_wrapped(input, &theme, 10);
assert!(
r.lines.len() >= 2,
"overlong word must produce at least 2 rows after hard split"
);
assert_eq!(
r.line_text[0].chars().count(),
10,
"first row must be exactly width chars for a hard split"
);
assert_eq!(r.line_text[1], "klmnopqrst");
assert_eq!(r.lines.len(), r.line_text.len());
assert_eq!(r.lines.len(), r.line_scales.len());
}
#[test]
fn wrapped_code_block_not_wrapped() {
let theme = Theme::default();
let long_code =
"fn very_long_function_name_that_exceeds_any_reasonable_width(arg: usize) -> usize { arg }";
let input = format!("```rust\n{long_code}\n```");
let base = render_markdown_to_styled(&input, &theme);
let wrapped = render_markdown_to_styled_wrapped(&input, &theme, 20);
assert_eq!(
base.lines.len(),
wrapped.lines.len(),
"code block must not be wrapped into extra rows"
);
assert_eq!(
base.line_text[1], wrapped.line_text[1],
"code-block content must be unchanged by wrapping"
);
assert_eq!(wrapped.code_blocks.len(), 1);
assert_eq!(wrapped.code_blocks[0].fence_open, 0);
assert_eq!(wrapped.code_blocks[0].fence_close, Some(2));
}
#[test]
fn wrapped_code_block_indices_remapped_when_body_lines_precede_it() {
let theme = Theme::default();
let input =
"This paragraph is longer than twenty characters and will wrap.\n```rust\nlet x = 1;\n```";
let wrapped = render_markdown_to_styled_wrapped(input, &theme, 20);
assert_eq!(wrapped.code_blocks.len(), 1);
let cb = &wrapped.code_blocks[0];
assert_eq!(
wrapped.line_text[cb.fence_open], " rust",
"fence_open must point to the lang-header row"
);
if let Some(close) = cb.fence_close {
assert!(
wrapped.line_text[close].is_empty(),
"fence_close must point to the blank closing row"
);
}
}
#[test]
fn wrapped_styled_spans_preserved_at_wrap_boundary() {
let theme = Theme::default();
let r = render_markdown_to_styled_wrapped("aaa **bold text** bbb", &theme, 8);
assert_eq!(
r.lines.len(),
2,
"expected 2 visual rows; got: {:?}",
r.line_text
);
assert_eq!(r.line_text[0], "aaa bold");
assert_eq!(r.line_text[1], "text bbb");
assert!(
r.lines[0].spans.iter().any(|s| s.bold && s.text == "bold"),
"bold styling must be present on row 0; spans: {:?}",
r.lines[0].spans
);
assert!(
r.lines[1].spans.iter().any(|s| s.bold && s.text == "text"),
"bold must continue at start of row 1; spans: {:?}",
r.lines[1].spans
);
let bbb_span = r.lines[1]
.spans
.iter()
.find(|s| s.text.trim() == "bbb")
.expect("expected a span containing 'bbb'");
assert!(
!bbb_span.bold,
"non-bold text after bold span must stay non-bold"
);
}
#[test]
fn wrapped_cjk_wraps_at_display_width_not_char_count() {
let theme = Theme::default();
let r = render_markdown_to_styled_wrapped("中文中文中文", &theme, 6);
assert!(
r.lines.len() >= 2,
"6 CJK chars (12 cells) at a 6-cell budget must wrap into \
at least 2 rows; got: {:?}",
r.line_text
);
for (i, lt) in r.line_text.iter().enumerate() {
assert!(
crate::text_util::display_width(lt) <= 6,
"row {i} exceeds the 6-cell budget: {lt:?}"
);
}
}
#[test]
fn wrapped_mixed_ascii_and_cjk_wraps_by_display_width_not_char_count() {
let theme = Theme::default();
let input = "ab 中文";
assert!(
input.chars().count() <= 6,
"test fixture must reproduce the naive char-count false negative"
);
assert!(
crate::text_util::display_width(input) > 6,
"test fixture must actually exceed the cell budget"
);
let r = render_markdown_to_styled_wrapped(input, &theme, 6);
assert!(
r.lines.len() >= 2,
"mixed ASCII+CJK line exceeding the cell budget must wrap into \
at least 2 rows; got: {:?}",
r.line_text
);
for (i, lt) in r.line_text.iter().enumerate() {
assert!(
crate::text_util::display_width(lt) <= 6,
"row {i} exceeds the 6-cell budget: {lt:?}"
);
}
let rejoined_non_space: String = r
.line_text
.iter()
.flat_map(|s| s.chars())
.filter(|c| !c.is_whitespace())
.collect();
let original_non_space: String = input.chars().filter(|c| !c.is_whitespace()).collect();
assert_eq!(rejoined_non_space, original_non_space);
}
#[test]
fn wrapped_all_vectors_length_aligned_for_various_inputs() {
let inputs: &[&str] = &[
"",
"short",
"a very long line that should definitely be wrapped because it exceeds the width",
"# Heading that is also quite long and might wrap at small widths",
"- bullet item that is a bit on the long side",
"1. numbered item with some extra text to trigger wrapping",
"> blockquote with plenty of words to cause a line break",
"```rust\nfn main() {}\n```",
"paragraph\n```rust\ncode\n```\nmore paragraph",
];
let theme = Theme::default();
for input in inputs {
let r = render_markdown_to_styled_wrapped(input, &theme, 30);
assert_eq!(
r.lines.len(),
r.line_text.len(),
"line_text mismatch for {input:?}"
);
assert_eq!(
r.lines.len(),
r.line_scales.len(),
"line_scales mismatch for {input:?}"
);
}
}
#[test]
fn wrapped_blockquote_continuation_rows_carry_pipe_glyph() {
let theme = Theme::default();
let input = "> This is a long blockquote that will definitely wrap";
let r = render_markdown_to_styled_wrapped(input, &theme, 20);
assert!(
r.lines.len() >= 2,
"long blockquote should produce ≥2 visual rows; got: {:?}",
r.line_text
);
for (i, lt) in r.line_text.iter().enumerate() {
assert!(
lt.starts_with('\u{2502}'),
"blockquote row {i} must start with │; got: {:?}",
lt
);
}
for (i, styled) in r.lines.iter().enumerate() {
assert!(
styled
.spans
.iter()
.any(|s| s.fg == Some(theme.link_fg) && s.text.contains('\u{2502}')),
"blockquote row {i} must have a link_fg │ span; spans: {:?}",
styled.spans
);
}
assert_eq!(r.lines.len(), r.line_text.len());
assert_eq!(r.lines.len(), r.line_scales.len());
}
#[test]
fn blockquote_emits_bar_glyph_and_link_fg() {
let theme = Theme::default();
let r = render_markdown_to_styled("> quoted text", &theme);
assert_eq!(r.lines.len(), 1);
assert!(
r.lines[0]
.spans
.iter()
.any(|s| s.fg == Some(theme.link_fg) && s.text.contains('\u{2502}')),
"expected a link_fg bar span; spans: {:?}",
r.lines[0].spans
);
assert!(r.line_text[0].contains('\u{2502}'));
assert!(r.line_text[0].contains("quoted text"));
}
#[test]
fn blockquote_inline_markup_is_parsed() {
let theme = Theme::default();
let r = render_markdown_to_styled("> **bold** inside quote", &theme);
assert!(
r.lines[0].spans.iter().any(|s| s.bold && s.text == "bold"),
"inline bold inside blockquote must be parsed; spans: {:?}",
r.lines[0].spans
);
}
#[test]
fn blockquote_scale_is_1_0() {
let theme = Theme::default();
let r = render_markdown_to_styled("> quote", &theme);
assert!((r.line_scales[0] - 1.0).abs() < f32::EPSILON);
}
#[test]
fn blockquote_link_inside_quote_tracked() {
let theme = Theme::default();
let r = render_markdown_to_styled("> see [here](http://example.com)", &theme);
assert_eq!(r.links.len(), 1);
let (line_idx, ref range, ref url) = r.links[0];
assert_eq!(line_idx, 0);
assert_eq!(&r.line_text[0][range.clone()], "here");
assert_eq!(url, "http://example.com");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn render_markdown_never_panics(s in ".{0,4000}") {
let theme = Theme::default();
let _ = render_markdown_to_styled(&s, &theme);
}
#[test]
fn render_markdown_never_panics_on_lossy_bytes(bytes in prop::collection::vec(any::<u8>(), 0..4000)) {
let s = String::from_utf8_lossy(&bytes);
let theme = Theme::default();
let _ = render_markdown_to_styled(&s, &theme);
}
#[test]
fn render_markdown_keeps_line_vectors_aligned(s in ".{0,4000}") {
let theme = Theme::default();
let r = render_markdown_to_styled(&s, &theme);
prop_assert_eq!(r.lines.len(), r.line_text.len());
prop_assert_eq!(r.lines.len(), r.line_scales.len());
}
#[test]
fn render_markdown_link_ranges_are_valid_into_line_text(s in ".{0,4000}") {
let theme = Theme::default();
let r = render_markdown_to_styled(&s, &theme);
for (line_idx, range, _url) in &r.links {
prop_assert!(*line_idx < r.line_text.len());
let line = &r.line_text[*line_idx];
prop_assert!(range.start <= range.end);
prop_assert!(range.end <= line.len());
prop_assert!(line.is_char_boundary(range.start));
prop_assert!(line.is_char_boundary(range.end));
}
}
}
}