use crate::settings::themes::Theme;
use pulldown_cmark::{HeadingLevel, Options, Tag};
use ratatui::style::{Modifier, Style};
#[cfg(test)]
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
mod block_opener;
mod detect;
mod parsed_buffer;
mod spanner;
pub(super) use block_opener::opener_shape;
pub use parsed_buffer::ParsedBuffer;
pub use spanner::MarkdownSpanner;
pub(super) const PARSER_OPTIONS: Options = Options::ENABLE_STRIKETHROUGH;
const TAB_STOP: usize = 4;
pub(super) fn tab_width_at(col: usize) -> usize {
TAB_STOP - (col % TAB_STOP)
}
pub(super) fn string_display_width(s: &str) -> usize {
s.graphemes(true).map(cluster_display_width).sum()
}
pub(super) fn blockquote_gutter(depth: u8) -> String {
let mut s = "│".repeat(depth as usize);
s.push(' ');
s
}
pub(super) fn blockquote_gutter_width(depth: u8) -> usize {
depth as usize + 1
}
pub(super) fn raw_display_width(line: &str) -> usize {
let mut col = 0usize;
for g in line.graphemes(true) {
col += cluster_width_at(g, col);
}
col
}
pub(super) fn cluster_width_at(cluster: &str, col: usize) -> usize {
if cluster == "\t" {
tab_width_at(col)
} else {
cluster_display_width(cluster)
}
}
pub(super) fn cluster_display_width(cluster: &str) -> usize {
cluster
.chars()
.next()
.and_then(unicode_width::UnicodeWidthChar::width)
.unwrap_or(1)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Element {
pub start_char: usize,
pub end_char: usize,
pub kind: ElementKind,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ElementKind {
Bold,
Italic,
Strikethrough,
InlineCode,
Link,
HeadingH1,
HeadingH2,
HeadingH3,
Blockquote,
WikiLink,
Image,
Label,
}
#[derive(Debug, Clone)]
pub struct ImagePlaceholder {
pub start_char: usize,
pub end_char: usize,
pub placeholder: String,
pub placeholder_width: usize,
}
#[derive(Debug, Clone)]
pub struct ParsedLine {
pub elements: Vec<Element>,
pub content_vis: Vec<bool>,
elem_vis: Vec<bool>,
elem_index: Vec<u16>,
modifier_mask: Vec<u8>,
list_sigil_end: Option<usize>,
pub image_placeholders: Vec<ImagePlaceholder>,
blockquote_depth: Option<u8>,
}
impl ParsedLine {
pub fn parse(line: &str) -> Self {
let owned = line.to_string();
if needs_synthetic_list_parent(line) {
ParsedBuffer::parse(&["- ".to_string(), owned])
.lines
.pop()
.expect("ParsedBuffer::parse returns one row per input line")
} else {
ParsedBuffer::parse(std::slice::from_ref(&owned))
.lines
.pop()
.expect("ParsedBuffer::parse always returns at least one ParsedLine")
}
}
pub fn elem_at(&self, pos: usize) -> Option<usize> {
self.elem_index.get(pos).and_then(|&tag| {
if tag == 0 {
None
} else {
Some((tag as usize) - 1)
}
})
}
pub fn in_any_element(&self, pos: usize) -> bool {
self.elem_vis.get(pos).copied().unwrap_or(false)
}
pub(super) fn modifiers_at(&self, pos: usize) -> u8 {
self.modifier_mask.get(pos).copied().unwrap_or(0)
}
pub fn heading_sigil_end(&self) -> Option<usize> {
self.elements
.iter()
.position(|e| {
matches!(
e.kind,
ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
)
})
.map(|idx| self.first_content_char(idx))
}
fn first_content_char(&self, elem_idx: usize) -> usize {
let e = &self.elements[elem_idx];
for i in e.start_char..e.end_char {
if i < self.content_vis.len() && self.content_vis[i] {
return i;
}
if self.elem_at(i).is_some_and(|inner| inner != elem_idx) {
return i;
}
}
e.end_char
}
pub fn list_sigil_end(&self) -> Option<usize> {
self.list_sigil_end
}
pub fn blockquote_depth(&self) -> Option<u8> {
self.blockquote_depth
}
pub fn blockquote_sigil_end(&self) -> Option<usize> {
self.elements
.iter()
.position(|e| e.kind == ElementKind::Blockquote)
.map(|idx| self.first_content_char(idx))
}
#[cfg(debug_assertions)]
pub(super) fn debug_assert_eq_to(&self, other: &Self, row: usize) {
assert_eq!(
self.content_vis, other.content_vis,
"row {row} content_vis diverge"
);
assert_eq!(self.elem_vis, other.elem_vis, "row {row} elem_vis diverge");
assert_eq!(
self.elem_index, other.elem_index,
"row {row} elem_index diverge"
);
assert_eq!(
self.list_sigil_end, other.list_sigil_end,
"row {row} list_sigil_end diverge"
);
assert_eq!(
self.blockquote_depth, other.blockquote_depth,
"row {row} blockquote_depth diverge"
);
assert_eq!(
self.elements.len(),
other.elements.len(),
"row {row} elements.len() diverge"
);
}
}
fn needs_synthetic_list_parent(line: &str) -> bool {
let trimmed = line.trim_start_matches([' ', '\t']);
if trimmed.len() == line.len() {
return false; }
list_marker_len(trimmed).is_some()
}
pub(super) fn leading_ws_byte_len(line: &str) -> usize {
line.bytes()
.take_while(|b| *b == b' ' || *b == b'\t')
.count()
}
pub(super) fn tag_to_kind(tag: &Tag) -> Option<ElementKind> {
Some(match tag {
Tag::Strong => ElementKind::Bold,
Tag::Emphasis => ElementKind::Italic,
Tag::Strikethrough => ElementKind::Strikethrough,
Tag::Link { .. } => ElementKind::Link,
Tag::BlockQuote(_) => ElementKind::Blockquote,
Tag::Heading { level, .. } => match level {
HeadingLevel::H1 => ElementKind::HeadingH1,
HeadingLevel::H2 => ElementKind::HeadingH2,
_ => ElementKind::HeadingH3,
},
_ => return None,
})
}
pub(super) fn list_marker_len(s: &str) -> Option<usize> {
if s.starts_with("- ") || s.starts_with("* ") || s.starts_with("+ ") {
return Some(2);
}
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > 0 && i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b' ' {
Some(i + 2)
} else {
None
}
}
pub(super) const MOD_BOLD: u8 = 1 << 0;
pub(super) const MOD_ITALIC: u8 = 1 << 1;
pub(super) const MOD_STRIKE: u8 = 1 << 2;
pub(super) fn modifier_bit(kind: ElementKind) -> u8 {
match kind {
ElementKind::Bold => MOD_BOLD,
ElementKind::Italic => MOD_ITALIC,
ElementKind::Strikethrough => MOD_STRIKE,
_ => 0,
}
}
pub(super) fn mask_to_modifier(mask: u8) -> Modifier {
let mut m = Modifier::empty();
if mask & MOD_BOLD != 0 {
m |= Modifier::BOLD;
}
if mask & MOD_ITALIC != 0 {
m |= Modifier::ITALIC;
}
if mask & MOD_STRIKE != 0 {
m |= Modifier::CROSSED_OUT;
}
m
}
pub(super) fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
match kind {
None => {
if is_sigil_region {
Style::default().fg(theme.gray.to_ratatui())
} else {
Style::default().fg(theme.fg.to_ratatui())
}
}
Some(ElementKind::Bold) => Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::BOLD),
Some(ElementKind::Italic) => Style::default()
.fg(theme.fg_secondary.to_ratatui())
.add_modifier(Modifier::ITALIC),
Some(ElementKind::Strikethrough) => Style::default()
.fg(theme.fg_secondary.to_ratatui())
.add_modifier(Modifier::CROSSED_OUT),
Some(ElementKind::InlineCode) => Style::default()
.fg(theme.aqua.to_ratatui())
.bg(theme.bg_soft.to_ratatui()),
Some(ElementKind::Link) => Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::UNDERLINED),
Some(ElementKind::Image) => Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::ITALIC),
Some(ElementKind::HeadingH1) | Some(ElementKind::HeadingH2) => {
if is_sigil_region {
Style::default().fg(theme.gray.to_ratatui())
} else {
Style::default()
.fg(theme.fg_bright.to_ratatui())
.add_modifier(Modifier::BOLD)
}
}
Some(ElementKind::HeadingH3) => {
if is_sigil_region {
Style::default().fg(theme.gray.to_ratatui())
} else {
Style::default()
.fg(theme.yellow.to_ratatui())
.add_modifier(Modifier::BOLD)
}
}
Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
Some(ElementKind::WikiLink) => Style::default()
.fg(theme.blue.to_ratatui())
.add_modifier(Modifier::UNDERLINED),
Some(ElementKind::Label) => Style::default()
.fg(theme.color_tag.to_ratatui())
.add_modifier(Modifier::BOLD),
}
}
#[cfg(test)]
mod tests {
use super::super::parse_incremental::LineConstructKind;
use super::*;
use ratatui::style::Modifier;
fn t() -> Theme {
Theme::default()
}
fn text(spans: &[Span]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn blockquote_lazy_continuation_carries_depth() {
let buf = ParsedBuffer::parse(&["> first".to_string(), "second".to_string()]);
assert_eq!(buf.lines[0].blockquote_depth(), Some(1));
assert_eq!(
buf.lines[1].blockquote_depth(),
Some(1),
"lazy continuation line must carry the blockquote depth"
);
let nested = ParsedBuffer::parse(&[">> a".to_string(), "b".to_string()]);
assert_eq!(nested.lines[0].blockquote_depth(), Some(2));
assert_eq!(nested.lines[1].blockquote_depth(), Some(2));
let ended =
ParsedBuffer::parse(&["> first".to_string(), String::new(), "plain".to_string()]);
assert_eq!(ended.lines[0].blockquote_depth(), Some(1));
assert_eq!(ended.lines[2].blockquote_depth(), None);
}
#[test]
fn indented_code_excludes_trailing_blank_keeps_interior() {
use super::super::parse_incremental::LineConstructKind::{Blank, IndentedCode, Plain};
let kinds = |lines: &[&str]| {
let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
ParsedBuffer::parse(&owned).kinds
};
assert_eq!(
kinds(&[" code", "", "outro"]),
vec![IndentedCode, Blank, Plain]
);
assert_eq!(
kinds(&[" a", "", " b"]),
vec![IndentedCode, IndentedCode, IndentedCode]
);
assert_eq!(
kinds(&[" a", "", "", "outro"]),
vec![IndentedCode, Blank, Blank, Plain]
);
assert_eq!(
kinds(&[" Line 1", " Line 2", "Line 3"]),
vec![IndentedCode, IndentedCode, Plain]
);
assert_eq!(
kinds(&[" line 1", " line 2", "", "", " line 3"]),
vec![
IndentedCode,
IndentedCode,
IndentedCode,
IndentedCode,
IndentedCode
]
);
}
#[test]
fn gutter_width_matches_rendered() {
for d in 1u8..=4 {
assert_eq!(
blockquote_gutter_width(d),
string_display_width(&blockquote_gutter(d)),
"gutter width/string disagree at depth {d}"
);
}
}
#[test]
fn blockquote_depth_and_sigil_end() {
let p = ParsedLine::parse("> hello");
assert_eq!(p.blockquote_depth(), Some(1));
assert_eq!(p.blockquote_sigil_end(), Some(2));
let p2 = ParsedLine::parse(">> deep");
assert_eq!(p2.blockquote_depth(), Some(2));
let plain = ParsedLine::parse("not a quote");
assert_eq!(plain.blockquote_depth(), None);
assert_eq!(plain.blockquote_sigil_end(), None);
}
#[test]
fn parse_bold_range() {
let e = MarkdownSpanner::parse_elements("**bold**");
let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
assert_eq!((b.start_char, b.end_char), (0, 8));
}
#[test]
fn parse_italic() {
assert!(
MarkdownSpanner::parse_elements("*hi*")
.iter()
.any(|e| e.kind == ElementKind::Italic)
);
}
#[test]
fn parse_strikethrough() {
let e = MarkdownSpanner::parse_elements("~~gone~~");
let s = e
.iter()
.find(|x| x.kind == ElementKind::Strikethrough)
.unwrap();
assert_eq!((s.start_char, s.end_char), (0, 8));
}
#[test]
fn strikethrough_renders_with_crossed_out_modifier() {
let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "gone");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
);
}
#[test]
fn parse_inline_code() {
assert!(
MarkdownSpanner::parse_elements("`x`")
.iter()
.any(|e| e.kind == ElementKind::InlineCode)
);
}
#[test]
fn parse_link() {
assert!(
MarkdownSpanner::parse_elements("[t](u)")
.iter()
.any(|e| e.kind == ElementKind::Link)
);
}
#[test]
fn parse_image_emits_image_element_and_placeholder() {
let line = "see  here";
let parsed = ParsedLine::parse(line);
let img = parsed
.elements
.iter()
.find(|e| e.kind == ElementKind::Image)
.expect("image element");
assert_eq!(line.chars().nth(img.start_char), Some('!'));
assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
let ph = parsed
.image_placeholders
.iter()
.find(|p| p.start_char == img.start_char)
.expect("placeholder for image");
assert_eq!(ph.placeholder, "[img.png]");
for pos in img.start_char..img.end_char {
assert!(
!parsed.content_vis[pos],
"char {pos} should be hidden inside image span"
);
}
}
#[test]
fn render_image_substitutes_placeholder_text() {
let line = "before  after";
let parsed = ParsedLine::parse(line);
let spans =
MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("[pic.gif]"),
"rendered text {rendered:?} should include placeholder"
);
assert!(
!rendered.contains("![alt]"),
"raw image syntax should not appear in rendered output: {rendered:?}"
);
}
#[test]
fn render_image_with_empty_alt_uses_filename() {
let line = "";
let parsed = ParsedLine::parse(line);
let spans =
MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(rendered, "[image.png]");
}
#[test]
fn rendered_cursor_col_accounts_for_placeholder_width() {
let line = "a  b";
let parsed = ParsedLine::parse(line);
let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
line,
&parsed,
0,
"a  b".chars().count(), true,
false,
);
assert_eq!(after_placeholder, 11);
}
#[test]
fn parse_h1() {
assert!(
MarkdownSpanner::parse_elements("# T")
.iter()
.any(|e| e.kind == ElementKind::HeadingH1)
);
}
#[test]
fn parse_h2() {
assert!(
MarkdownSpanner::parse_elements("## T")
.iter()
.any(|e| e.kind == ElementKind::HeadingH2)
);
}
#[test]
fn parse_h3() {
assert!(
MarkdownSpanner::parse_elements("### T")
.iter()
.any(|e| e.kind == ElementKind::HeadingH3)
);
}
#[test]
fn force_raw_no_styling() {
let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
assert_eq!(text(&s), "**x**");
assert!(
!s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn plain_text_passthrough() {
let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "hi");
}
#[test]
fn bold_without_cursor_hides_markers() {
let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "bold");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn bold_cursor_inside_shows_raw() {
let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
assert_eq!(text(&s), "**bold**");
}
#[test]
fn bold_cursor_outside_stays_rendered() {
let line = "hello **bold** world";
let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
assert!(!text(&s).contains("**"));
}
#[test]
fn italic_cursor_inside_shows_raw() {
let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
assert_eq!(text(&s), "*hi*");
}
#[test]
fn inline_code_hides_backticks() {
let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "x");
}
#[test]
fn h1_first_line_contains_hash() {
let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
assert!(text(&s).contains('#'));
assert!(text(&s).contains('T'));
}
#[test]
fn continuation_line_no_hash() {
let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
assert!(!text(&s).contains('#'));
}
#[test]
fn unordered_list_shows_marker() {
let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
assert!(
text(&s).starts_with("- "),
"expected '- item', got '{}'",
text(&s)
);
assert!(text(&s).contains("item"));
}
#[test]
fn ordered_list_shows_marker() {
let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
assert!(
text(&s).starts_with("1. "),
"expected '1. item', got '{}'",
text(&s)
);
}
#[test]
fn nested_list_4space_link_rendered() {
let line = " - [my link](url)";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
"link text should be underlined on a 4-space-indented nested list item"
);
let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
rendered.contains("my link"),
"link display text should be visible; got {:?}",
rendered
);
assert!(
!rendered.contains("](url)"),
"link URL sigil should be hidden; got {:?}",
rendered
);
}
#[test]
fn nested_list_tab_bold_rendered() {
let line = "\t- **bold nested**";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
"bold text should be styled on a tab-indented nested list item"
);
let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!rendered.contains("**"),
"bold markers should be hidden; got {:?}",
rendered
);
}
#[test]
fn nested_list_4space_wikilink_rendered() {
let line = " - [[Target Note]]";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!rendered.contains("[["),
"wikilink brackets should be hidden; got {:?}",
rendered
);
assert!(
rendered.contains("Target Note"),
"wikilink target text should render; got {:?}",
rendered
);
}
#[test]
fn nested_list_2space_still_renders_link() {
let line = " - [link](url)";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
);
}
#[test]
fn empty_heading_shows_hash_sigil() {
let line = "# ";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert!(
text(&s).contains('#'),
"hash sigil should render in empty heading"
);
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
}
#[test]
fn empty_heading_hash_only_shows() {
let line = "#";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert!(text(&s).contains('#'));
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
assert_eq!(col, 1);
}
#[test]
fn heading_trailing_spaces_are_rendered() {
let line = "# Hello ";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(
text(&s),
"# Hello ",
"trailing spaces in heading should render"
);
}
#[test]
fn heading_trailing_spaces_cursor_col_correct() {
let line = "# Hello ";
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
assert_eq!(
col, 9,
"cursor in trailing space of heading should map to rendered col 9"
);
}
#[test]
fn trailing_spaces_are_rendered() {
let line = "hello ";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "hello ");
}
#[test]
fn trailing_spaces_cursor_col_correct() {
let line = "hello ";
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
assert_eq!(col, 7);
}
#[test]
fn list_marker_on_continuation_line_hidden() {
let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
assert!(!text(&s).starts_with("- "));
}
#[test]
fn parsed_line_heading_sigil_end_empty_heading() {
let p = ParsedLine::parse("#");
assert_eq!(p.heading_sigil_end(), Some(1));
}
#[test]
fn parsed_line_heading_sigil_end_with_content() {
let p = ParsedLine::parse("# T");
assert_eq!(p.heading_sigil_end(), Some(2));
}
#[test]
fn parsed_line_reuse_matches_individual() {
let line = "**hello** world";
let parsed = ParsedLine::parse(line);
let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
assert_eq!(
s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
);
}
#[test]
fn parse_wikilink() {
let e = MarkdownSpanner::parse_elements("[[My Note]]");
let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
assert_eq!((wl.start_char, wl.end_char), (0, 11));
}
#[test]
fn wikilink_without_cursor_hides_brackets() {
let line = "[[My Note]]";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "My Note");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
);
}
#[test]
fn wikilink_cursor_inside_shows_brackets() {
let line = "[[My Note]]";
let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
assert_eq!(text(&s), "[[My Note]]");
}
#[test]
fn wikilink_cursor_outside_hides_brackets() {
let line = "hello [[My Note]] world";
let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
assert!(!text(&s).contains("[["));
assert!(!text(&s).contains("]]"));
}
#[test]
fn wikilink_in_heading_rendered() {
let line = "# See [[Topic]]";
let e = MarkdownSpanner::parse_elements(line);
assert!(
e.iter().any(|x| x.kind == ElementKind::WikiLink),
"wikilink inside heading should produce a WikiLink element"
);
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "# See Topic", "wikilink brackets hidden, # kept");
}
#[test]
fn heading_with_link_does_not_leak_bracket() {
let line = "# [text](http://x)";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "# text");
}
#[test]
fn heading_with_bold_does_not_leak_asterisk() {
let line = "# **bold**";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(
text(&s),
"# bold",
"leading * must not leak into heading sigil"
);
}
#[test]
fn bold_wikilink_is_bold() {
let line = "**[[Topic]]**";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "Topic");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)
&& sp.content.contains("Topic")),
"wikilink wrapped in ** must render bold"
);
}
#[test]
fn italic_link_is_italic() {
let line = "*[text](http://x)*";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "text");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::ITALIC)
&& sp.content.contains("text")),
"link wrapped in * must render italic"
);
}
#[test]
fn bold_italic_wikilink_is_bold_and_italic() {
let line = "***[[Topic]]***";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "Topic");
assert!(
s.iter().any(|sp| {
sp.content.contains("Topic")
&& sp.style.add_modifier.contains(Modifier::BOLD)
&& sp.style.add_modifier.contains(Modifier::ITALIC)
}),
"wikilink in *** *** must render both bold and italic"
);
}
#[test]
fn bold_italic_plain_text() {
let line = "***text***";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "text");
assert!(
s.iter().any(|sp| {
sp.content.contains("text")
&& sp.style.add_modifier.contains(Modifier::BOLD)
&& sp.style.add_modifier.contains(Modifier::ITALIC)
}),
"*** *** must render both bold and italic"
);
}
#[test]
fn wikilink_mid_sentence() {
let line = "See [[Topic]] for details";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "See Topic for details");
}
#[test]
fn wikilink_cursor_col_accounts_for_brackets() {
let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
assert_eq!(col, 2);
let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
assert_eq!(col2, 0);
}
#[test]
fn buffer_parse_nested_list_under_parent() {
let lines = vec".to_string(),
];
let parsed = ParsedBuffer::parse(&lines).lines;
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].list_sigil_end(), Some(2));
assert_eq!(
parsed[1].list_sigil_end(),
Some(6),
"child's sigil_end should be after ' - ' (6 chars)"
);
assert!(
parsed[1]
.elements
.iter()
.any(|e| e.kind == ElementKind::Link),
"nested list item should contain a Link element"
);
}
#[test]
fn buffer_parse_standalone_2space_list_still_works() {
let lines = vec".to_string()];
let parsed = ParsedBuffer::parse(&lines).lines;
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::Link)
);
assert_eq!(parsed[0].list_sigil_end(), Some(4));
}
#[test]
fn buffer_parse_top_level_unchanged() {
let lines = vec".to_string()];
let parsed = ParsedBuffer::parse(&lines).lines;
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::Link)
);
assert_eq!(parsed[0].list_sigil_end(), Some(2));
}
#[test]
fn buffer_parse_empty_lines_preserved() {
let lines = vec![
"# Title".to_string(),
String::new(),
"paragraph".to_string(),
];
let parsed = ParsedBuffer::parse(&lines).lines;
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[1].elements.len(), 0);
assert_eq!(parsed[1].content_vis.len(), 0);
}
#[test]
fn buffer_parse_ordered_nested_list() {
let lines = vec!["1. first".to_string(), " 1. nested".to_string()];
let parsed = ParsedBuffer::parse(&lines).lines;
assert_eq!(parsed[0].list_sigil_end(), Some(3));
assert_eq!(parsed[1].list_sigil_end(), Some(7));
}
#[test]
fn buffer_parse_setext_h1_spans_two_rows() {
let lines = vec!["My Heading".to_string(), "==========".to_string()];
let parsed = ParsedBuffer::parse(&lines).lines;
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::HeadingH1),
"setext underline must tag row 0 as HeadingH1"
);
assert!(
parsed[1]
.elements
.iter()
.any(|e| e.kind == ElementKind::HeadingH1),
"setext underline must tag row 1 as HeadingH1"
);
assert!(
parsed[1].content_vis.iter().all(|v| !v),
"setext underline row has no content"
);
}
#[test]
fn buffer_parse_multiline_blockquote() {
let lines = vec!["> first line".to_string(), "> second line".to_string()];
let parsed = ParsedBuffer::parse(&lines).lines;
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::Blockquote),
"row 0 must tag as Blockquote"
);
assert!(
parsed[1]
.elements
.iter()
.any(|e| e.kind == ElementKind::Blockquote),
"row 1 must tag as Blockquote"
);
}
#[test]
fn parse_line_emits_label_for_hashtag() {
let line = "see #rust later";
let parsed = ParsedLine::parse(line);
let label = parsed
.elements
.iter()
.find(|e| matches!(e.kind, ElementKind::Label));
assert!(
label.is_some(),
"expected Label element: {:?}",
parsed.elements
);
let l = label.unwrap();
let span: String = line
.chars()
.skip(l.start_char)
.take(l.end_char - l.start_char)
.collect();
assert_eq!(span, "#rust");
}
#[test]
fn parse_line_skips_label_inside_inline_code() {
let parsed = ParsedLine::parse("use `#foo` here");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(!has_label, "should not emit Label inside inline code");
}
#[test]
fn parse_line_skips_label_inside_markdown_link() {
let parsed = ParsedLine::parse("[see docs](#section) and #real");
let labels: Vec<_> = parsed
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert_eq!(
labels.len(),
1,
"only #real should be a label, not #section in the link"
);
let l = labels[0];
let span: String = "[see docs](#section) and #real"
.chars()
.skip(l.start_char)
.take(l.end_char - l.start_char)
.collect();
assert_eq!(span, "#real");
}
#[test]
fn parse_line_skips_label_inside_link_display_text() {
let parsed = ParsedLine::parse("[#todo](notes/project.md)");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(
!has_label,
"hashtag inside link display text should not become Label"
);
}
#[test]
fn parse_line_skips_label_after_label_char() {
let parsed = ParsedLine::parse("foo#bar baz");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(
!has_label,
"word#tag should not emit Label without word boundary"
);
}
#[test]
fn parse_line_skips_label_for_double_hash() {
let parsed = ParsedLine::parse("##draft");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(!has_label, "##draft should not emit Label");
}
#[test]
fn parse_line_skips_label_for_adjacent_hash_run() {
let parsed = ParsedLine::parse("#tag#more");
let labels: Vec<_> = parsed
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert!(
labels.is_empty(),
"#tag#more should not emit Label, got {:?}",
labels
);
}
#[test]
fn parse_buffer_skips_label_inside_fenced_block() {
let buffer = vec![
"before".to_string(),
"```".to_string(),
"#inside".to_string(),
"```".to_string(),
"after #outside".to_string(),
];
let lines = ParsedBuffer::parse(&buffer).lines;
let inside_labels: Vec<_> = lines[2]
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert!(
inside_labels.is_empty(),
"no Label emitted for hashtags in fenced blocks"
);
let outside_labels: Vec<_> = lines[4]
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert_eq!(outside_labels.len(), 1, "#outside still extracted");
}
#[test]
fn parse_range_full_equals_parse() {
let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
let full = ParsedBuffer::parse(&lines);
let range_full = ParsedBuffer::parse_range(&lines, 0..lines.len());
assert_eq!(full.lines.len(), range_full.lines.len());
assert_eq!(full.kinds, range_full.kinds);
for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
assert_eq!(a.content_vis, b.content_vis);
assert_eq!(a.elements.len(), b.elements.len());
}
}
#[test]
fn parse_range_paragraph_only_slice() {
let lines: Vec<String> = vec![
"intro paragraph".into(),
"".into(),
"middle line".into(),
"".into(),
"outro".into(),
];
let slice = ParsedBuffer::parse_range(&lines, 2..3);
assert_eq!(slice.lines.len(), 1);
assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
}
#[test]
fn splice_replaces_range() {
let mut pb = ParsedBuffer::parse(&["alpha".into(), "beta".into(), "gamma".into()]);
let replacement = ParsedBuffer::parse(&["BETA-NEW".into()]);
let replacement_kind = replacement.kinds[0];
pb.splice(1..2, replacement);
assert_eq!(pb.lines.len(), 3);
assert_eq!(pb.kinds.len(), 3);
assert_eq!(
pb.kinds[1], replacement_kind,
"replacement landed at the wrong index"
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "splice")]
fn splice_panics_on_length_mismatch_in_debug() {
let mut pb = ParsedBuffer::parse(&["a".into(), "b".into()]);
let too_short = ParsedBuffer::parse(&["X".into()]);
pb.splice(0..2, too_short);
}
#[test]
fn lazy_depth_blockquote_closes_at_first_blank() {
let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
let pb = ParsedBuffer::parse(&lines);
assert_eq!(
pb.lazy_depth,
vec![1, 0, 0, 0],
"blockquote closes at first blank per CommonMark §5.1; got {:?}",
pb.lazy_depth,
);
}
#[test]
fn lazy_depth_indented_code_across_blanks() {
let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
let pb = ParsedBuffer::parse(&lines);
assert_eq!(
pb.lazy_depth,
vec![1, 1, 1],
"indented code multi-chunk should keep lazy_depth > 0 across the blank \
AND through the last content row; got {:?}",
pb.lazy_depth,
);
}
#[test]
fn lazy_depth_fenced_code_does_not_count() {
let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
let pb = ParsedBuffer::parse(&lines);
assert_eq!(
pb.lazy_depth,
vec![0, 0, 0, 0],
"fenced code is not lazy-continuable; got {:?}",
pb.lazy_depth,
);
}
#[test]
fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
let lines: Vec<String> = vec!["> a".into(), "".into()];
let pb = ParsedBuffer::parse(&lines);
assert_eq!(
pb.lazy_depth,
vec![1, 0],
"blockquote must close at the trailing blank; got {:?}",
pb.lazy_depth,
);
assert!(
pb.reset_boundaries.contains(&1),
"the trailing blank at row 1 must be a reset boundary; got {:?}",
pb.reset_boundaries,
);
}
#[test]
fn boundaries_skip_rows_inside_lazy_block() {
let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
let pb = ParsedBuffer::parse(&lines);
assert_eq!(
pb.reset_boundaries,
vec![0, lines.len()],
"no boundary should land on a blank row inside the open indented-code block; \
got {:?}",
pb.reset_boundaries,
);
}
}