From 9999d5d052932431dbfead02361bc86299acbf97 Mon Sep 17 00:00:00 2001 From: RivoLink Date: Sat, 11 Jul 2026 11:09:27 +0300 Subject: [PATCH] fix: blockquote indent in list --- src/markdown/blocks.rs | 8 +- src/markdown/lists.rs | 94 +++++---- src/markdown/mod.rs | 24 ++- src/tests/markdown_list_blocks.rs | 318 ++++++++++++++++++++++++++++++ src/tests/markdown_lists.rs | 103 ---------- src/tests/mod.rs | 1 + 6 files changed, 392 insertions(+), 156 deletions(-) create mode 100644 src/tests/markdown_list_blocks.rs diff --git a/src/markdown/blocks.rs b/src/markdown/blocks.rs index 069f258..c884358 100644 --- a/src/markdown/blocks.rs +++ b/src/markdown/blocks.rs @@ -59,14 +59,14 @@ pub(super) fn flush_wrapped_spans( push_wrapped_blockquote_lines(lines, spans, render_width, theme, marker_color); } else if !item_stack.is_empty() { let first_prefix = list_item_prefix( - blockquote_depth > 0, + blockquote_depth, list_stack, item_stack, theme, marker_color, ); let continuation_prefix = list_item_prefix( - blockquote_depth > 0, + blockquote_depth, list_stack, item_stack, theme, @@ -177,7 +177,7 @@ pub(super) fn push_code_block_lines( ) -> BlockLayout { let prefix = if !item_stack.is_empty() { list_item_prefix( - ctx.blockquote_depth > 0, + ctx.blockquote_depth, ctx.list_stack, item_stack, ctx.theme_colors, @@ -310,7 +310,7 @@ pub(super) fn push_special_block_lines Vec>>( let show_line_numbers = ctx.show_line_numbers; let center = ctx.center; let prefix = if !item_stack.is_empty() { - list_item_prefix(blockquote_depth > 0, list_stack, item_stack, theme, None) + list_item_prefix(blockquote_depth, list_stack, item_stack, theme, None) } else if blockquote_depth > 0 { block_prefix(true, theme, None) } else { diff --git a/src/markdown/lists.rs b/src/markdown/lists.rs index eb814f4..e100441 100644 --- a/src/markdown/lists.rs +++ b/src/markdown/lists.rs @@ -25,58 +25,71 @@ pub(super) struct ItemState { pub(super) marker_emitted: bool, pub(super) continuation_indent: usize, pub(super) checkbox: Option, + pub(super) opened_at_bq_depth: usize, } pub(super) fn list_item_prefix( - in_bq: bool, + blockquote_depth: usize, list_stack: &[ListKind], item_stack: &mut [ItemState], theme: &MarkdownTheme, marker_color: Option, ) -> Vec> { - let mut prefix = block_prefix(in_bq, theme, marker_color); + let in_bq = blockquote_depth > 0; let Some(item) = item_stack.last_mut() else { - return prefix; + return block_prefix(in_bq, theme, marker_color); }; - if item.marker_emitted { - prefix.push(Span::raw(" ".repeat(item.continuation_indent))); - return prefix; + let bq_is_outer = in_bq && blockquote_depth == item.opened_at_bq_depth; + + let mut prefix = Vec::new(); + + if bq_is_outer { + prefix.extend(block_prefix(in_bq, theme, marker_color)); } - let depth = list_stack.len(); - prefix.push(Span::raw(" ".repeat(depth.saturating_sub(1)))); - - let marker: Cow<'static, str> = match item.checkbox { - // Avoids U+2611 emoji rendering on Windows. - Some(true) if cfg!(target_os = "windows") => TASK_CHECKED_ALT.into(), - Some(true) => TASK_CHECKED.into(), - Some(false) => TASK_UNCHECKED.into(), - None => match list_stack.last().copied().unwrap_or(ListKind::Unordered) { - ListKind::Unordered => match depth { - 1 => "• ".into(), - 2 => "◦ ".into(), - _ => "▸ ".into(), + if item.marker_emitted { + prefix.push(Span::raw(" ".repeat(item.continuation_indent))); + } else { + let depth = list_stack.len(); + prefix.push(Span::raw(" ".repeat(depth.saturating_sub(1)))); + + let marker: Cow<'static, str> = match item.checkbox { + // Avoids U+2611 emoji rendering on Windows. + Some(true) if cfg!(target_os = "windows") => TASK_CHECKED_ALT.into(), + Some(true) => TASK_CHECKED.into(), + Some(false) => TASK_UNCHECKED.into(), + None => match list_stack.last().copied().unwrap_or(ListKind::Unordered) { + ListKind::Unordered => match depth { + 1 => "• ".into(), + 2 => "◦ ".into(), + _ => "▸ ".into(), + }, + ListKind::Ordered(n) => format!("{n}. ").into(), }, - ListKind::Ordered(n) => format!("{n}. ").into(), - }, - }; - item.continuation_indent = " ".repeat(depth.saturating_sub(1)).len() + display_width(&marker); - item.marker_emitted = true; - - let marker_style = match item.checkbox { - Some(true) => Style::default().fg(theme.task_checked), - Some(false) => Style::default().fg(theme.task_unchecked), - None => match list_stack.last().copied().unwrap_or(ListKind::Unordered) { - ListKind::Unordered => match depth { - 1 => Style::default().fg(theme.list_level_1), - 2 => Style::default().fg(theme.list_level_2), - _ => Style::default().fg(theme.list_level_3), + }; + item.continuation_indent = 2 * depth.saturating_sub(1) + display_width(&marker); + item.marker_emitted = true; + + let marker_style = match item.checkbox { + Some(true) => Style::default().fg(theme.task_checked), + Some(false) => Style::default().fg(theme.task_unchecked), + None => match list_stack.last().copied().unwrap_or(ListKind::Unordered) { + ListKind::Unordered => match depth { + 1 => Style::default().fg(theme.list_level_1), + 2 => Style::default().fg(theme.list_level_2), + _ => Style::default().fg(theme.list_level_3), + }, + ListKind::Ordered(_) => Style::default().fg(theme.ordered_list), }, - ListKind::Ordered(_) => Style::default().fg(theme.ordered_list), - }, - }; - prefix.push(Span::styled(marker, marker_style)); + }; + prefix.push(Span::styled(marker, marker_style)); + } + + if in_bq && !bq_is_outer { + prefix.extend(block_prefix(in_bq, theme, marker_color)); + } + prefix } @@ -96,14 +109,14 @@ pub(super) fn flush_list_item_spans( } let first_prefix = list_item_prefix( - blockquote_depth > 0, + blockquote_depth, list_stack, item_stack, theme, marker_color, ); let continuation_prefix = list_item_prefix( - blockquote_depth > 0, + blockquote_depth, list_stack, item_stack, theme, @@ -138,11 +151,12 @@ pub(super) fn end_list(lines: &mut Vec>, list_stack: &mut Vec) { +pub(super) fn start_item(item_stack: &mut Vec, blockquote_depth: usize) { item_stack.push(ItemState { marker_emitted: false, continuation_indent: 0, checkbox: None, + opened_at_bq_depth: blockquote_depth, }); } diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index 219e111..b1945ce 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -46,7 +46,8 @@ use blocks::{ use fences::normalize_code_fences; use links::build_link_spans; use lists::{ - end_item, end_list, flush_list_item_spans, start_item, start_list, ItemState, ListKind, + end_item, end_list, flush_list_item_spans, list_item_prefix, start_item, start_list, ItemState, + ListKind, }; #[cfg(test)] pub(crate) use lists::{TASK_CHECKED, TASK_CHECKED_ALT, TASK_UNCHECKED}; @@ -542,13 +543,18 @@ pub(crate) fn parse_markdown_with_width( let color = alert_color(k, theme_colors); blockquote_color = Some(color); let (icon, label) = alert_icon_label(k); - lines.push(Line::from(vec![ - Span::styled("▏ ", Style::default().fg(color)), - Span::styled( - format!("{icon} {label}"), - Style::default().fg(color).add_modifier(Modifier::BOLD), - ), - ])); + let mut header = list_item_prefix( + blockquote_depth, + &list_stack, + &mut item_stack, + theme_colors, + blockquote_color, + ); + header.push(Span::styled( + format!("{icon} {label}"), + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + lines.push(Line::from(header)); } } MdEvent::End(TagEnd::BlockQuote(_)) => { @@ -584,7 +590,7 @@ pub(crate) fn parse_markdown_with_width( last_block = LastBlock::Other; } MdEvent::Start(Tag::Item) => { - start_item(&mut item_stack); + start_item(&mut item_stack, blockquote_depth); } MdEvent::End(TagEnd::Item) => { end_item( diff --git a/src/tests/markdown_list_blocks.rs b/src/tests/markdown_list_blocks.rs new file mode 100644 index 0000000..d73a424 --- /dev/null +++ b/src/tests/markdown_list_blocks.rs @@ -0,0 +1,318 @@ +use super::{rendered_non_empty_lines, test_assets, test_md_theme}; +use crate::markdown::{parse_markdown, parse_markdown_with_width}; + +#[test] +fn code_block_in_list_item_has_same_layout_loose_and_tight() { + let (ss, theme) = test_assets(); + let loose_md = "- one\n\n ```\n three\n ```\n"; + let tight_md = "- one\n ```\n three\n ```\n"; + let (loose_lines, _, _, _) = + parse_markdown(loose_md, &ss, &theme, &test_md_theme(), false, true).into(); + let (tight_lines, _, _, _) = + parse_markdown(tight_md, &ss, &theme, &test_md_theme(), false, true).into(); + let loose = rendered_non_empty_lines(&loose_lines); + let tight = rendered_non_empty_lines(&tight_lines); + + let loose_one = loose + .iter() + .position(|l| l.contains("one")) + .expect("missing 'one' in loose rendering"); + let loose_three = loose + .iter() + .position(|l| l.contains("three")) + .expect("missing 'three' in loose rendering"); + let tight_one = tight + .iter() + .position(|l| l.contains("one")) + .expect("missing 'one' in tight rendering"); + let tight_three = tight + .iter() + .position(|l| l.contains("three")) + .expect("missing 'three' in tight rendering"); + + assert_eq!( + loose_three - loose_one, + tight_three - tight_one, + "loose and tight paths should place the code block at the same distance from the item line" + ); +} + +#[test] +fn blockquote_after_continuation_line_in_tight_list_item_renders_below_it() { + let (ss, theme) = test_assets(); + let md = "- one\n two\n > quote\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + + let one_idx = rendered + .iter() + .position(|line| line.contains("one")) + .expect("missing first list line"); + let two_idx = rendered + .iter() + .position(|line| line.contains("two")) + .expect("missing continuation line"); + let quote_idx = rendered + .iter() + .position(|line| line.contains("quote")) + .expect("missing blockquote line"); + + assert!( + one_idx < two_idx && two_idx < quote_idx, + "expected blockquote below continuation line, got one={one_idx}, two={two_idx}, quote={quote_idx}" + ); +} + +#[test] +fn nested_list_after_continuation_line_in_tight_list_item_renders_below_it() { + let (ss, theme) = test_assets(); + let md = "- one\n two\n - nested\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + + let one_idx = rendered + .iter() + .position(|line| line.contains("one")) + .expect("missing first list line"); + let two_idx = rendered + .iter() + .position(|line| line.contains("two")) + .expect("missing continuation line"); + let nested_idx = rendered + .iter() + .position(|line| line.contains("nested")) + .expect("missing nested list line"); + + assert!( + one_idx < two_idx && two_idx < nested_idx, + "expected nested list below continuation line, got one={one_idx}, two={two_idx}, nested={nested_idx}" + ); +} + +#[test] +fn html_block_after_continuation_line_in_tight_list_item_documented_behavior() { + let (ss, theme) = test_assets(); + let md = "- one\n two\n
html
\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + + assert!(rendered.iter().any(|line| line.contains("one"))); + assert!(rendered.iter().any(|line| line.contains("two"))); + assert!( + !rendered.iter().any(|line| line.contains("html")), + "documented behavior: raw HTML blocks are dropped from the rendered output" + ); +} + +#[test] +fn list_items_inside_blockquote_render_bq_marker_before_list_marker() { + let (ss, theme) = test_assets(); + let md = "> - Step one\n> - Step two\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let step_one = rendered + .iter() + .find(|line| line.contains("Step one")) + .expect("missing 'Step one' line"); + + let bar_idx = step_one + .find('▏') + .expect("expected blockquote marker in rendered line"); + let bullet_idx = step_one + .find("• ") + .expect("expected list marker in rendered line"); + assert!( + bar_idx < bullet_idx, + "blockquote marker should precede list marker when list is inside blockquote, got {step_one:?}" + ); +} + +#[test] +fn list_inside_alert_places_bq_marker_before_list_marker() { + let (ss, theme) = test_assets(); + let md = "> [!NOTE]\n> Steps to follow:\n> - Step one\n> - Step two\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let step_line = rendered + .iter() + .find(|line| line.contains("Step one")) + .expect("missing 'Step one' line"); + + assert!( + step_line.starts_with("▏ • "), + "list item inside alert should start with '▏ • ', got {step_line:?}" + ); +} + +#[test] +fn alert_header_in_list_item_is_indented_under_item_content() { + let (ss, theme) = test_assets(); + let md = "- info list item\n > [!NOTE]\n > This is a note inside a tight list item.\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let header = rendered + .iter() + .find(|line| line.contains("Note")) + .expect("missing alert header"); + + assert!( + header.starts_with(" ▏"), + "alert header should be indented under item content, got {header:?}" + ); +} + +#[test] +fn blockquote_in_tight_list_item_is_indented_under_item_content() { + let (ss, theme) = test_assets(); + let md = "- one\n > quote\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let quote_line = rendered + .iter() + .find(|line| line.contains("quote")) + .expect("missing blockquote line"); + + assert!( + quote_line.starts_with(" ▏"), + "blockquote in list item should be indented under item content, got {quote_line:?}" + ); +} + +#[test] +fn blockquote_after_continuation_in_tight_list_item_is_indented() { + let (ss, theme) = test_assets(); + let md = "- one\n two\n > quote\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let quote_line = rendered + .iter() + .find(|line| line.contains("quote")) + .expect("missing blockquote line"); + + assert!( + quote_line.starts_with(" ▏"), + "blockquote after continuation should be indented, got {quote_line:?}" + ); +} + +#[test] +fn loose_blockquote_in_list_item_is_indented_under_item_content() { + let (ss, theme) = test_assets(); + let md = "- one\n\n > quote\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let quote_line = rendered + .iter() + .find(|line| line.contains("quote")) + .expect("missing blockquote line"); + + assert!( + quote_line.starts_with(" ▏"), + "loose blockquote in list item should be indented, got {quote_line:?}" + ); +} + +#[test] +fn blockquote_multiline_source_in_list_item_indents_all_lines() { + let (ss, theme) = test_assets(); + let md = "- one\n > line1\n > line2\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let line1 = rendered + .iter() + .find(|line| line.contains("line1")) + .expect("missing line1"); + let line2 = rendered + .iter() + .find(|line| line.contains("line2")) + .expect("missing line2"); + + assert!( + line1.starts_with(" ▏"), + "line1 should be indented: {line1:?}" + ); + assert!( + line2.starts_with(" ▏"), + "line2 should be indented: {line2:?}" + ); +} + +#[test] +fn blockquote_long_line_in_list_item_wraps_with_correct_indent() { + let (ss, theme) = test_assets(); + let md = "- one\n > This is a long blockquote line that should wrap into multiple prefixed lines at a narrow width.\n"; + let (lines, _, _, _) = + parse_markdown_with_width(md, &ss, &theme, 32, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let quoted: Vec<_> = rendered.iter().filter(|line| line.contains('▏')).collect(); + + assert!( + quoted.len() >= 2, + "expected the blockquote to wrap into multiple lines, got {quoted:?}" + ); + assert!( + quoted.iter().all(|line| line.starts_with(" ▏")), + "all wrapped blockquote lines should share the list-indent prefix, got {quoted:?}" + ); +} + +#[test] +fn nested_blockquote_in_list_item_uses_list_indent() { + let (ss, theme) = test_assets(); + let md = "- > > deep\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let deep_line = rendered + .iter() + .find(|line| line.contains("deep")) + .expect("missing deep line"); + + assert!( + deep_line.contains("• "), + "list marker should be present on the item opening: {deep_line:?}" + ); + assert!( + deep_line.contains('▏'), + "blockquote marker should be present: {deep_line:?}" + ); + let bullet_idx = deep_line.find("• ").unwrap(); + let bar_idx = deep_line.find('▏').unwrap(); + assert!( + bullet_idx < bar_idx, + "list marker should come before blockquote marker: {deep_line:?}" + ); +} + +#[test] +fn ordered_list_item_with_blockquote_uses_correct_indent() { + let (ss, theme) = test_assets(); + let md = "1. one\n > quote\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let quote_line = rendered + .iter() + .find(|line| line.contains("quote")) + .expect("missing blockquote line"); + + assert!( + quote_line.starts_with(" ▏"), + "ordered list item should indent blockquote by 3 columns (width of '1. '), got {quote_line:?}" + ); +} + +#[test] +fn nested_list_with_blockquote_uses_deeper_indent() { + let (ss, theme) = test_assets(); + let md = "- outer\n - inner\n > deep quote\n"; + let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + let quote_line = rendered + .iter() + .find(|line| line.contains("deep quote")) + .expect("missing deep quote line"); + + assert!( + quote_line.starts_with(" ▏"), + "nested list item should indent blockquote by 4 columns (2 for outer + 2 for inner '◦ '), got {quote_line:?}" + ); +} diff --git a/src/tests/markdown_lists.rs b/src/tests/markdown_lists.rs index 7ef08e9..b4eb58c 100644 --- a/src/tests/markdown_lists.rs +++ b/src/tests/markdown_lists.rs @@ -309,106 +309,3 @@ fn code_block_after_continuation_line_in_tight_list_item_renders_below_it() { "expected continuation line directly after first line" ); } - -#[test] -fn code_block_in_list_item_has_same_layout_loose_and_tight() { - let (ss, theme) = test_assets(); - let loose_md = "- one\n\n ```\n three\n ```\n"; - let tight_md = "- one\n ```\n three\n ```\n"; - let (loose_lines, _, _, _) = - parse_markdown(loose_md, &ss, &theme, &test_md_theme(), false, true).into(); - let (tight_lines, _, _, _) = - parse_markdown(tight_md, &ss, &theme, &test_md_theme(), false, true).into(); - let loose = rendered_non_empty_lines(&loose_lines); - let tight = rendered_non_empty_lines(&tight_lines); - - let loose_one = loose - .iter() - .position(|l| l.contains("one")) - .expect("missing 'one' in loose rendering"); - let loose_three = loose - .iter() - .position(|l| l.contains("three")) - .expect("missing 'three' in loose rendering"); - let tight_one = tight - .iter() - .position(|l| l.contains("one")) - .expect("missing 'one' in tight rendering"); - let tight_three = tight - .iter() - .position(|l| l.contains("three")) - .expect("missing 'three' in tight rendering"); - - assert_eq!( - loose_three - loose_one, - tight_three - tight_one, - "loose and tight paths should place the code block at the same distance from the item line" - ); -} - -#[test] -fn blockquote_after_continuation_line_in_tight_list_item_renders_below_it() { - let (ss, theme) = test_assets(); - let md = "- one\n two\n > quote\n"; - let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); - let rendered = rendered_non_empty_lines(&lines); - - let one_idx = rendered - .iter() - .position(|line| line.contains("one")) - .expect("missing first list line"); - let two_idx = rendered - .iter() - .position(|line| line.contains("two")) - .expect("missing continuation line"); - let quote_idx = rendered - .iter() - .position(|line| line.contains("quote")) - .expect("missing blockquote line"); - - assert!( - one_idx < two_idx && two_idx < quote_idx, - "expected blockquote below continuation line, got one={one_idx}, two={two_idx}, quote={quote_idx}" - ); -} - -#[test] -fn nested_list_after_continuation_line_in_tight_list_item_renders_below_it() { - let (ss, theme) = test_assets(); - let md = "- one\n two\n - nested\n"; - let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); - let rendered = rendered_non_empty_lines(&lines); - - let one_idx = rendered - .iter() - .position(|line| line.contains("one")) - .expect("missing first list line"); - let two_idx = rendered - .iter() - .position(|line| line.contains("two")) - .expect("missing continuation line"); - let nested_idx = rendered - .iter() - .position(|line| line.contains("nested")) - .expect("missing nested list line"); - - assert!( - one_idx < two_idx && two_idx < nested_idx, - "expected nested list below continuation line, got one={one_idx}, two={two_idx}, nested={nested_idx}" - ); -} - -#[test] -fn html_block_after_continuation_line_in_tight_list_item_documented_behavior() { - let (ss, theme) = test_assets(); - let md = "- one\n two\n
html
\n"; - let (lines, _, _, _) = parse_markdown(md, &ss, &theme, &test_md_theme(), false, true).into(); - let rendered = rendered_non_empty_lines(&lines); - - assert!(rendered.iter().any(|line| line.contains("one"))); - assert!(rendered.iter().any(|line| line.contains("two"))); - assert!( - !rendered.iter().any(|line| line.contains("html")), - "documented behavior: raw HTML blocks are dropped from the rendered output" - ); -} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 16925b2..20c72e9 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -22,6 +22,7 @@ mod inline; mod markdown_blocks; mod markdown_embedded; mod markdown_links; +mod markdown_list_blocks; mod markdown_lists; mod markdown_tables; mod render;