From 486f10dc6c5bf3c9006ff0185faff822be5b092c Mon Sep 17 00:00:00 2001 From: RivoLink Date: Sun, 12 Jul 2026 10:07:49 +0300 Subject: [PATCH] fix: blockquote depth markers --- src/markdown/blocks.rs | 57 ++++++-- src/markdown/lists.rs | 6 +- src/markdown/mod.rs | 60 ++++++-- src/tests/markdown_blocks.rs | 220 +++++++++++++++++++++++++++++- src/tests/markdown_list_blocks.rs | 71 +++++++++- 5 files changed, 383 insertions(+), 31 deletions(-) diff --git a/src/markdown/blocks.rs b/src/markdown/blocks.rs index c884358..23dd48e 100644 --- a/src/markdown/blocks.rs +++ b/src/markdown/blocks.rs @@ -21,26 +21,47 @@ pub(super) struct BlockLayout { } pub(super) fn block_prefix( - in_bq: bool, + blockquote_depth: usize, theme: &MarkdownTheme, marker_color: Option, ) -> Vec> { - if in_bq { - let color = marker_color.unwrap_or(theme.blockquote_marker); - vec![Span::styled("▏ ", Style::default().fg(color))] - } else { - vec![] + if blockquote_depth == 0 { + return vec![]; + } + let default_color = theme.blockquote_marker; + let outer_color = marker_color.unwrap_or(default_color); + let outer_style = Style::default().fg(outer_color); + let inner_style = Style::default().fg(default_color); + let mut spans = Vec::with_capacity(blockquote_depth); + spans.push(Span::styled("▏ ", outer_style)); + for _ in 1..blockquote_depth { + spans.push(Span::styled("▏ ", inner_style)); + } + spans +} + +fn is_blockquote_blank_text(plain: &str) -> bool { + !plain.is_empty() && plain.chars().all(|c| c == '▏' || c.is_whitespace()) +} + +pub(super) fn pop_trailing_blockquote_gap(lines: &mut Vec>) { + if lines + .last() + .is_some_and(|line| is_blockquote_blank_text(&super::width::line_plain_text(line))) + { + lines.pop(); } } pub(super) fn push_wrapped_blockquote_lines( lines: &mut Vec>, body_spans: &mut Vec>, + blockquote_depth: usize, render_width: usize, theme: &MarkdownTheme, marker_color: Option, ) { - let prefix = block_prefix(true, theme, marker_color); + let prefix = block_prefix(blockquote_depth, theme, marker_color); push_wrapped_prefixed_lines(lines, body_spans, prefix.clone(), prefix, render_width); } @@ -56,7 +77,14 @@ pub(super) fn flush_wrapped_spans( marker_color: Option, ) { if blockquote_depth > 0 && item_stack.is_empty() { - push_wrapped_blockquote_lines(lines, spans, render_width, theme, marker_color); + push_wrapped_blockquote_lines( + lines, + spans, + blockquote_depth, + render_width, + theme, + marker_color, + ); } else if !item_stack.is_empty() { let first_prefix = list_item_prefix( blockquote_depth, @@ -88,10 +116,11 @@ pub(super) fn trim_paragraph_gap_before_block( lines: &mut Vec>, last_block: LastBlock, ) { - if last_block == LastBlock::Paragraph - && lines - .last() - .is_some_and(|line| super::width::line_plain_text(line).is_empty()) + if matches!(last_block, LastBlock::Paragraph | LastBlock::Blockquote) + && lines.last().is_some_and(|line| { + let plain = super::width::line_plain_text(line); + plain.is_empty() || is_blockquote_blank_text(&plain) + }) { lines.pop(); } @@ -184,7 +213,7 @@ pub(super) fn push_code_block_lines( None, ) } else if ctx.blockquote_depth > 0 { - block_prefix(true, ctx.theme_colors, None) + block_prefix(ctx.blockquote_depth, ctx.theme_colors, None) } else { Vec::new() }; @@ -312,7 +341,7 @@ pub(super) fn push_special_block_lines Vec>>( let prefix = if !item_stack.is_empty() { list_item_prefix(blockquote_depth, list_stack, item_stack, theme, None) } else if blockquote_depth > 0 { - block_prefix(true, theme, None) + block_prefix(blockquote_depth, theme, None) } else { Vec::new() }; diff --git a/src/markdown/lists.rs b/src/markdown/lists.rs index e100441..69edf25 100644 --- a/src/markdown/lists.rs +++ b/src/markdown/lists.rs @@ -37,7 +37,7 @@ pub(super) fn list_item_prefix( ) -> Vec> { let in_bq = blockquote_depth > 0; let Some(item) = item_stack.last_mut() else { - return block_prefix(in_bq, theme, marker_color); + return block_prefix(blockquote_depth, theme, marker_color); }; let bq_is_outer = in_bq && blockquote_depth == item.opened_at_bq_depth; @@ -45,7 +45,7 @@ pub(super) fn list_item_prefix( let mut prefix = Vec::new(); if bq_is_outer { - prefix.extend(block_prefix(in_bq, theme, marker_color)); + prefix.extend(block_prefix(blockquote_depth, theme, marker_color)); } if item.marker_emitted { @@ -87,7 +87,7 @@ pub(super) fn list_item_prefix( } if in_bq && !bq_is_outer { - prefix.extend(block_prefix(in_bq, theme, marker_color)); + prefix.extend(block_prefix(blockquote_depth, theme, marker_color)); } prefix diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index b1945ce..24c2989 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -39,9 +39,10 @@ use std::{ use toc::{normalize_toc, TocEntry}; use blocks::{ - flush_wrapped_spans, push_code_block_lines, push_heading_lines, push_latex_block_lines, - push_mermaid_block_lines, push_rule_line, trim_paragraph_gap_before_block, BlockLayout, - CodeBlockRenderContext, EmbeddedBlockCtx, CODE_BLOCK_GUTTER, + block_prefix, flush_wrapped_spans, pop_trailing_blockquote_gap, push_code_block_lines, + push_heading_lines, push_latex_block_lines, push_mermaid_block_lines, push_rule_line, + trim_paragraph_gap_before_block, BlockLayout, CodeBlockRenderContext, EmbeddedBlockCtx, + CODE_BLOCK_GUTTER, }; use fences::normalize_code_fences; use links::build_link_spans; @@ -63,6 +64,7 @@ const LINK_MARKER: &str = "#"; enum LastBlock { Other, Paragraph, + Blockquote, } pub(crate) fn hash_str(text: &str) -> u64 { @@ -157,7 +159,22 @@ fn end_paragraph( theme, marker_color, ); - lines.push(Line::from("")); + let gap = if blockquote_depth > 0 { + if !item_stack.is_empty() { + Line::from(list_item_prefix( + blockquote_depth, + list_stack, + item_stack, + theme, + marker_color, + )) + } else { + Line::from(block_prefix(blockquote_depth, theme, marker_color)) + } + } else { + Line::from("") + }; + lines.push(gap); } #[allow(clippy::too_many_arguments)] @@ -195,13 +212,21 @@ fn end_blockquote( marker_color: Option, ) { if !spans.is_empty() { - let color = marker_color.unwrap_or(theme.blockquote_marker); - let mut all = vec![Span::styled("▏ ", Style::default().fg(color))]; + let mut all = block_prefix(*blockquote_depth, theme, marker_color); all.append(spans); lines.push(Line::from(all)); } + pop_trailing_blockquote_gap(lines); *blockquote_depth = blockquote_depth.saturating_sub(1); - lines.push(Line::from("")); + if *blockquote_depth == 0 { + lines.push(Line::from("")); + } else { + lines.push(Line::from(block_prefix( + *blockquote_depth, + theme, + marker_color, + ))); + } } fn alert_icon_label(kind: BlockQuoteKind) -> (&'static str, &'static str) { @@ -364,6 +389,7 @@ pub(crate) fn parse_markdown_with_width( let mut last_block = LastBlock::Other; let mut link_urls: Vec = Vec::new(); let mut blockquote_color: Option = None; + let mut prev_event_end: usize = 0; let normalized = normalize_code_fences(src); let line_starts = compute_line_starts(&normalized); @@ -385,6 +411,7 @@ pub(crate) fn parse_markdown_with_width( } else { state.mark_all_new(lines.len()); } + prev_event_end = range.end; continue; } if handle_inline_style_event( @@ -395,6 +422,7 @@ pub(crate) fn parse_markdown_with_width( blockquote_depth, &mut link_urls, ) { + prev_event_end = range.end; continue; } @@ -538,6 +566,21 @@ pub(crate) fn parse_markdown_with_width( ) { wraps = true; } + let source_between = normalized.get(prev_event_end..range.start).unwrap_or(""); + let has_explicit_gap = source_between.split_inclusive('\n').any(|line| { + if !line.ends_with('\n') { + return false; + } + let content = line.trim_end_matches('\n').trim_start(); + content.starts_with('>') + && content.get(1..).is_none_or(|rest| rest.trim().is_empty()) + }); + if last_block == LastBlock::Paragraph + && (!item_stack.is_empty() || blockquote_depth > 0) + && !has_explicit_gap + { + trim_paragraph_gap_before_block(&mut lines, last_block); + } blockquote_depth += 1; if let Some(k) = kind { let color = alert_color(k, theme_colors); @@ -566,7 +609,7 @@ pub(crate) fn parse_markdown_with_width( blockquote_color.take(), ); wraps = true; - last_block = LastBlock::Other; + last_block = LastBlock::Blockquote; } MdEvent::Start(Tag::List(start)) => { if !item_stack.is_empty() && !spans.is_empty() { @@ -680,6 +723,7 @@ pub(crate) fn parse_markdown_with_width( } else { state.mark_all_new(lines.len()); } + prev_event_end = range.end; } if !spans.is_empty() { diff --git a/src/tests/markdown_blocks.rs b/src/tests/markdown_blocks.rs index 8e2695a..ad80363 100644 --- a/src/tests/markdown_blocks.rs +++ b/src/tests/markdown_blocks.rs @@ -27,15 +27,146 @@ fn paragraph_and_following_code_block_have_no_blank_gap() { assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); } +#[test] +fn returning_from_nested_blockquote_inserts_outer_prefix_gap() { + let (ss, theme) = test_assets(); + let src = "> outer\n>\n> > nested\n>\n> back\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let nested_idx = rendered + .iter() + .position(|l| l == "▏ ▏ nested") + .expect("missing nested line"); + let back_idx = rendered + .iter() + .position(|l| l == "▏ back") + .expect("missing back line"); + assert!(nested_idx < back_idx); + let between = &rendered[nested_idx + 1..back_idx]; + assert!( + between.iter().any(|l| l == "▏ "), + "expected an outer-depth prefixed blank between nested and back, got {between:?}" + ); +} + +#[test] +fn blockquote_last_paragraph_has_no_trailing_prefixed_blank_inside() { + let (ss, theme) = test_assets(); + let src = "> a\n> b\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let last_bq_idx = rendered + .iter() + .rposition(|l| l.starts_with('▏')) + .expect("expected at least one blockquote line"); + assert_eq!( + rendered[last_bq_idx], "▏ b", + "last blockquote line should be the content, not a prefixed blank, got {rendered:?}" + ); +} + +#[test] +fn consecutive_blockquotes_preserve_blank_line_between() { + let (ss, theme) = test_assets(); + let src = "> [!NOTE]\n> Note content.\n\n> [!TIP]\n> Tip content.\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let note_content_idx = rendered + .iter() + .position(|l| l == "▏ Note content.") + .expect("missing Note content line"); + let tip_header_idx = rendered + .iter() + .position(|l| l.contains("Tip")) + .expect("missing Tip header"); + assert!(note_content_idx < tip_header_idx); + let between = &rendered[note_content_idx + 1..tip_header_idx]; + assert!( + between.iter().any(|l| l.is_empty()), + "expected a blank separator between consecutive blockquotes, got {between:?}" + ); +} + +#[test] +fn top_level_paragraph_before_blockquote_preserves_blank_separator() { + let (ss, theme) = test_assets(); + let src = "vs\n\n> a\n> b\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let vs_idx = rendered + .iter() + .position(|l| l == "vs") + .expect("missing 'vs'"); + let bq_idx = rendered + .iter() + .position(|l| l == "▏ a") + .expect("missing '▏ a'"); + assert!(vs_idx < bq_idx); + let between = &rendered[vs_idx + 1..bq_idx]; + assert!( + between.iter().any(|l| l.is_empty()), + "expected blank separator between top-level paragraph and blockquote, got {between:?}" + ); +} + +#[test] +fn nested_blockquote_directly_after_paragraph_has_no_prefixed_gap_between() { + let (ss, theme) = test_assets(); + let src = "> [!WARNING]\n> Warning message.\n> > Nested inside warning.\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let msg_idx = rendered + .iter() + .position(|l| l == "▏ Warning message.") + .expect("missing warning message"); + let nested_idx = rendered + .iter() + .position(|l| l == "▏ ▏ Nested inside warning.") + .expect("missing nested line"); + assert_eq!( + nested_idx, + msg_idx + 1, + "expected nested blockquote directly after paragraph without prefixed gap, got {rendered:?}" + ); +} + +#[test] +fn explicit_bq_gap_before_nested_produces_prefixed_blank() { + let (ss, theme) = test_assets(); + let src = "> outer paragraph\n>\n> > nested paragraph\n>\n> back to outer\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let outer_idx = rendered + .iter() + .position(|l| l == "▏ outer paragraph") + .expect("missing outer"); + let nested_idx = rendered + .iter() + .position(|l| l == "▏ ▏ nested paragraph") + .expect("missing nested"); + assert!(outer_idx < nested_idx); + let between = &rendered[outer_idx + 1..nested_idx]; + assert!( + between.iter().any(|l| l == "▏ "), + "expected a prefixed blank between outer paragraph and nested (source has '>' empty), got {between:?}" + ); +} + #[test] fn nested_blockquotes_keep_quote_prefix_after_inner_quote_ends() { let (ss, theme) = test_assets(); - let src = "> outer\n> > inner\n> outer again\n"; + let src = "> outer\n>\n> > inner\n>\n> outer again\n"; let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); let rendered = rendered_non_empty_lines(&lines); assert!(rendered.iter().any(|line| line == "▏ outer")); - assert!(rendered.iter().any(|line| line == "▏ inner")); + assert!(rendered.iter().any(|line| line == "▏ ▏ inner")); assert!(rendered.iter().any(|line| line == "▏ outer again")); } @@ -55,6 +186,91 @@ fn long_blockquotes_wrap_into_multiple_prefixed_lines() { assert!(quoted.iter().all(|line| line.starts_with("▏ "))); } +#[test] +fn nested_blockquote_emits_depth_two_markers() { + let (ss, theme) = test_assets(); + let src = "> > inner\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + + assert!(rendered.iter().any(|line| line == "▏ ▏ inner")); +} + +#[test] +fn triple_nested_blockquote_emits_depth_three_markers() { + let (ss, theme) = test_assets(); + let src = "> > > deepest\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + + assert!(rendered.iter().any(|line| line == "▏ ▏ ▏ deepest")); +} + +#[test] +fn nested_blockquote_returns_to_single_marker_after_close() { + let (ss, theme) = test_assets(); + let src = "> outer\n>\n> > inner\n>\n> back\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered = rendered_non_empty_lines(&lines); + + let outer_idx = rendered.iter().position(|l| l == "▏ outer").expect("outer"); + let inner_idx = rendered + .iter() + .position(|l| l == "▏ ▏ inner") + .expect("inner"); + let back_idx = rendered.iter().position(|l| l == "▏ back").expect("back"); + assert!(outer_idx < inner_idx && inner_idx < back_idx); +} + +#[test] +fn blank_line_between_paragraphs_in_blockquote_is_prefixed() { + let (ss, theme) = test_assets(); + let src = "> para 1\n>\n> para 2\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let p1_idx = rendered + .iter() + .position(|l| l == "▏ para 1") + .expect("para 1"); + let p2_idx = rendered + .iter() + .position(|l| l == "▏ para 2") + .expect("para 2"); + assert!(p1_idx < p2_idx); + let between = &rendered[p1_idx + 1..p2_idx]; + assert!( + between.iter().any(|l| l == "▏ "), + "expected a blockquote-prefixed blank line between paragraphs, got {between:?}" + ); +} + +#[test] +fn alert_header_outer_color_only_applies_to_first_marker() { + let (ss, theme) = test_assets(); + let src = "> [!NOTE]\n> > nested\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let nested_line = lines + .iter() + .find(|l| line_plain_text(l).contains("nested")) + .expect("nested line"); + let markers: Vec<_> = nested_line + .spans + .iter() + .filter(|s| s.content.as_ref().contains('▏')) + .collect(); + + assert_eq!( + markers.len(), + 2, + "expected 2 blockquote markers, got {markers:?}" + ); + let alert_color = test_md_theme().alert_note; + let default_color = test_md_theme().blockquote_marker; + assert_eq!(markers[0].style.fg, Some(alert_color)); + assert_eq!(markers[1].style.fg, Some(default_color)); +} + #[test] fn frontmatter_is_ignored_in_preview() { let (ss, theme) = test_assets(); diff --git a/src/tests/markdown_list_blocks.rs b/src/tests/markdown_list_blocks.rs index d73a424..a10049b 100644 --- a/src/tests/markdown_list_blocks.rs +++ b/src/tests/markdown_list_blocks.rs @@ -1,6 +1,51 @@ use super::{rendered_non_empty_lines, test_assets, test_md_theme}; +use crate::line_plain_text; use crate::markdown::{parse_markdown, parse_markdown_with_width}; +#[test] +fn code_block_after_blockquote_in_list_item_has_no_blank_gap_before() { + let (ss, theme) = test_assets(); + let src = "- header\n > quote first\n ```\n code after quote\n ```\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let quote_idx = rendered + .iter() + .position(|l| l == " ▏ quote first") + .expect("missing blockquote line"); + let header_idx = rendered + .iter() + .position(|l| l.contains("┌─")) + .expect("missing code block header"); + assert_eq!( + header_idx, + quote_idx + 1, + "expected code block header directly after blockquote line, got {rendered:?}" + ); +} + +#[test] +fn blockquote_after_list_item_paragraph_has_no_blank_gap_before() { + let (ss, theme) = test_assets(); + let src = "1. first item\n > quote\n\n2. second item\n > another quote\n"; + let (lines, _, _, _) = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true).into(); + let rendered: Vec = lines.iter().map(line_plain_text).collect(); + + let first_idx = rendered + .iter() + .position(|l| l == "1. first item") + .expect("missing '1. first item'"); + let quote_idx = rendered + .iter() + .position(|l| l == " ▏ quote") + .expect("missing blockquote line"); + assert_eq!( + quote_idx, + first_idx + 1, + "expected blockquote directly under the item content, got {rendered:?}" + ); +} + #[test] fn code_block_in_list_item_has_same_layout_loose_and_tight() { let (ss, theme) = test_assets(); @@ -271,15 +316,33 @@ fn nested_blockquote_in_list_item_uses_list_indent() { 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 markers = deep_line.matches('▏').count(); + assert_eq!( + markers, 2, + "expected 2 blockquote markers for depth 2, got {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:?}" + "list marker should come before blockquote markers: {deep_line:?}" + ); +} + +#[test] +fn nested_blockquote_inside_list_item_uses_depth_markers() { + 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("▏ ▏ "), + "expected two blockquote markers in the prefix: {deep_line:?}" ); }