//! Render output types for markdown. use std::ops::Range; use ratatui::text::Line; use crate::buffers::CodeBlockMeta; /// A hyperlink target extracted from rendered markdown. /// /// Each instance maps a contiguous cell range on one rendered line to a URL. /// When a link wraps across lines, multiple `HyperlinkTarget`s share the same /// `id` and `url` -- the `id` enables OSC 8 hover-grouping across wrapped lines. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HyperlinkTarget { /// Index of the rendered line this target appears on. pub line_index: usize, /// Column range (in display cells) of the link text on that line. pub column_range: Range, /// The destination URL. pub url: String, /// Stable identifier for grouping link fragments that belong to the /// same logical link (e.g., a link whose text wraps across lines). pub id: u32, } /// A fenced code block discovered while rendering markdown. /// /// One `CodeBlockSpan` is produced per **closed** fenced code block, in /// document order. An unterminated (still-open) fence at the end of the input /// produces no span: `pulldown-cmark` synthesizes a block end at end-of-input, /// so closure is detected structurally (a closing fence must follow the body) /// rather than from the end event alone. /// /// This is a generic, reusable description of a fenced block — it is not /// specific to any one info string (e.g. `mermaid`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct CodeBlockSpan { /// The fence info string, e.g. `"mermaid"` or `"rust"`. /// /// Empty for a fence opened with no info (just ` ``` `). Reported verbatim /// as `pulldown-cmark` yields it (the full info string, not just the first /// word). pub info: String, /// The fence body content — the clean, container-stripped code/diagram /// source. /// /// This is `pulldown-cmark`'s merged body text, so container markers are /// removed (a blockquote `>` / list indentation does **not** leak in) and /// CRLF line endings are normalized to `\n`. It ends with the body's /// trailing newline and is empty for an empty-body fence. Prefer this over /// slicing [`source_byte_range`](Self::source_byte_range) when you need the /// logical body (e.g. a Mermaid diagram nested in a blockquote). pub body: String, /// Range of **pre-wrap** rendered body lines for this block, as indices /// into [`MarkdownRenderOutput::lines`] / [`MarkdownRenderView::lines`]. /// /// Covers only the body — the delimiter ` ``` ` lines are excluded — so it /// is independent of whether the renderer hides those delimiters in pretty /// mode. Empty (`start == end`) for a fence with an empty body. pub output_line_range: Range, /// Byte range of the fence body in the **raw** source text. /// /// Spans from the first body byte to the last, with the delimiter fence /// lines excluded; empty (`start == end`) for an empty body. Unlike /// [`body`](Self::body) this is a raw slice, so for a fence nested in a /// blockquote or list it covers the source between the delimiters and may /// include container markers/indentation (and `\r` for CRLF) on /// continuation lines. Use [`body`](Self::body) for the clean content. pub source_byte_range: Range, } /// Output from rendering markdown to ratatui Lines. /// /// Contains all the information needed to display rendered markdown and /// support copy operations back to source text. #[derive(Debug, Clone, Default)] pub struct MarkdownRenderOutput { /// Rendered lines ready for display. pub lines: Vec>, /// Maps each rendered line index to its source line number. /// `line_source_map[rendered_line_idx]` = source line number (0-indexed). pub line_source_map: Vec, /// Maps a cell range on a rendered line to a URL. Links that /// wrap across lines produce multiple entries with the same `id` and `url`. pub hyperlinks: Vec, /// Fenced code blocks discovered during rendering, in document order. /// One entry per closed fenced block; see [`CodeBlockSpan`]. pub code_blocks: Vec, } impl MarkdownRenderOutput { /// Create a new empty output. pub fn new() -> Self { Self::default() } /// Clear all content, keeping allocated capacity. pub fn clear(&mut self) { self.lines.clear(); self.line_source_map.clear(); self.hyperlinks.clear(); self.code_blocks.clear(); } /// Get a borrowed view of this output. pub fn as_view(&self) -> MarkdownRenderView<'_> { MarkdownRenderView { lines: &self.lines, line_source_map: &self.line_source_map, hyperlinks: &self.hyperlinks, code_blocks: &self.code_blocks, } } } /// Borrowed view of rendered markdown output. /// /// This is a zero-copy reference to rendered content, used by the streaming /// renderer to avoid cloning frozen content on every render. #[derive(Debug, Clone, Copy)] pub struct MarkdownRenderView<'a> { /// Rendered lines ready for display. pub lines: &'a [Line<'static>], /// Maps each rendered line index to its source line number. pub line_source_map: &'a [usize], /// Hyperlink targets extracted from the rendered markdown. pub hyperlinks: &'a [HyperlinkTarget], /// Fenced code blocks discovered during rendering, in document order. /// One entry per closed fenced block; see [`CodeBlockSpan`]. pub code_blocks: &'a [CodeBlockSpan], } impl<'a> MarkdownRenderView<'a> { /// Get the number of lines. pub fn line_count(&self) -> usize { self.lines.len() } } /// Map parse-time code-block metadata onto the rendered output. /// /// Runs after `render_ratatui` has produced `line_source_map`, turning each /// captured [`CodeBlockMeta`] into a public [`CodeBlockSpan`]. The /// pre-wrap body line range is derived from `line_source_map`: a fence body /// occupies source lines `[src_first, src_last]`, and because the renderer /// emits exactly one output line per body source line (and never maps a /// non-body line into that source-line range), the matching output lines form /// one contiguous run. `line_source_map` is non-decreasing, so the run is /// located with two `partition_point`s. /// /// Cost is O(text_len + lines·log) per render: the metas are in ascending body /// order, so newline counts come from a single monotonic forward cursor over /// `text` rather than rescanning from byte 0 for every meta (which would be /// O(metas·text_len) — quadratic in the number of fences on the streaming hot /// path). pub(crate) fn build_code_block_spans( text: &str, line_source_map: &[usize], metas: Vec, ) -> Vec { if metas.is_empty() { return Vec::new(); } let bytes = text.as_bytes(); // Monotonic newline cursor. Each query advances from the previous position // (metas ascend by body offset), so the whole pass is O(text_len). '\n' is // single-byte ASCII, so byte counting is UTF-8-safe at any offset. let mut cursor_pos = 0usize; let mut cursor_newlines = 0usize; let mut newlines_before = |pos: usize| -> usize { let pos = pos.min(bytes.len()); debug_assert!( pos >= cursor_pos, "metas must be processed in ascending body order", ); while cursor_pos < pos { if bytes[cursor_pos] == b'\n' { cursor_newlines += 1; } cursor_pos += 1; } cursor_newlines }; metas .into_iter() .map(|meta| { let range = meta.body_source_range; let src_first = newlines_before(range.start); let output_line_range = if range.end <= range.start { // Empty body: no rendered body lines. Anchor an empty range at // the first output line that does not precede the body. let start = line_source_map.partition_point(|&src| src < src_first); start..start } else { // `range.end - 1` is the last body byte; its source line is the // inclusive last body source line, robust to a trailing newline. let src_last = newlines_before(range.end - 1); let start = line_source_map.partition_point(|&src| src < src_first); let end = line_source_map.partition_point(|&src| src <= src_last); start..end }; CodeBlockSpan { info: meta.info, body: meta.body, output_line_range, source_byte_range: range, } }) .collect() } #[cfg(test)] mod code_block_span_tests { use ratatui::text::Line; use crate::style::test_style::STYLE; use crate::{CodeBlockSpan, StreamingMarkdownRenderer, render_markdown_ratatui_full}; fn lines_text(lines: &[Line<'static>]) -> Vec { lines .iter() .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) .collect() } /// Plain text of the rendered body lines a span points at. fn body_lines(lines: &[Line<'static>], span: &CodeBlockSpan) -> Vec { lines_text(&lines[span.output_line_range.clone()]) } /// Source bytes a span's `source_byte_range` selects. fn body_source<'a>(src: &'a str, span: &CodeBlockSpan) -> &'a str { &src[span.source_byte_range.clone()] } fn blocks(src: &str, pretty: bool) -> (Vec>, Vec) { let (out, _) = render_markdown_ratatui_full(src, STYLE, pretty, None); (out.lines, out.code_blocks) } #[test] fn closed_fence_top_level_pretty_and_raw() { // A non-rendered language (`text`): its rendered body lines are the // verbatim source, so the span's `output_line_range` maps back to them. // (A `mermaid` fence renders to diagram art instead — see // `mermaid_fence_renders_inline_but_span_keeps_clean_source`.) let src = "```text\nflowchart TD\n A --> B\n```\n"; for pretty in [true, false] { let (lines, cbs) = blocks(src, pretty); assert_eq!(cbs.len(), 1, "pretty={pretty}"); assert_eq!(cbs[0].info, "text"); // Body line range excludes the delimiter fences in both modes. assert_eq!( body_lines(&lines, &cbs[0]), vec!["flowchart TD", " A --> B"], "pretty={pretty}", ); // Byte range and clean body are mode-independent; for a top-level // fence both equal the verbatim fence body. assert_eq!(body_source(src, &cbs[0]), "flowchart TD\n A --> B\n"); assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n"); } } #[test] fn open_fence_produces_no_span() { // Unterminated fence at EOF: pulldown still emits a block end, but no // span must be produced (malformed/partial input). for src in [ "```mermaid\nflowchart TD\n", "```mermaid\nflowchart TD", "```mermaid", "intro\n\n```rust\nlet x = 1;", ] { for pretty in [true, false] { let (_, cbs) = blocks(src, pretty); assert!( cbs.is_empty(), "open fence {src:?} (pretty={pretty}) should yield no span, got {cbs:?}", ); } } } #[test] fn multiple_interspersed_blocks_in_order() { let src = "Intro\n\n```rust\nfn a() {}\n```\n\nMid prose\n\n```text\nA-->B\n```\n\nEnd\n"; for pretty in [true, false] { let (lines, cbs) = blocks(src, pretty); assert_eq!(cbs.len(), 2, "pretty={pretty}"); assert_eq!(cbs[0].info, "rust"); assert_eq!(cbs[1].info, "text"); assert_eq!(body_source(src, &cbs[0]), "fn a() {}\n"); assert_eq!(body_source(src, &cbs[1]), "A-->B\n"); assert_eq!(body_lines(&lines, &cbs[0]), vec!["fn a() {}"]); assert_eq!(body_lines(&lines, &cbs[1]), vec!["A-->B"]); // Document order ⇒ disjoint, increasing line ranges. assert!(cbs[0].output_line_range.end <= cbs[1].output_line_range.start); } } #[test] fn fence_nested_in_list() { // Multi-line body so the list's base indent stripping is exercised on a // continuation line (" A --> B" → " A --> B"). let src = "- item\n ```mermaid\n flowchart TD\n A --> B\n ```\n- next\n"; for pretty in [true, false] { let (_lines, cbs) = blocks(src, pretty); assert_eq!(cbs.len(), 1, "pretty={pretty}"); assert_eq!(cbs[0].info, "mermaid"); // `body` is the clean, de-prefixed source: the list base indent is // stripped but inner relative indentation is preserved. assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n", "pretty={pretty}"); // The raw byte range, by contrast, also strips the per-line base // indent here (pulldown's text-event range starts after it). assert_eq!( body_source(src, &cbs[0]), "flowchart TD\n A --> B\n", "pretty={pretty}", ); assert!(!cbs[0].output_line_range.is_empty()); } } #[test] fn fence_nested_in_blockquote() { // The motivating case for the structural closure rule: the closing // fence line is "> ```" (not a bare fence), and the body must come out // de-prefixed (no leaked "> " / "│ "). let src = "> ```mermaid\n> flowchart TD\n> A --> B\n> ```\n"; for pretty in [true, false] { let (_lines, cbs) = blocks(src, pretty); assert_eq!(cbs.len(), 1, "pretty={pretty}"); assert_eq!(cbs[0].info, "mermaid"); assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n", "pretty={pretty}"); } } #[test] fn open_blockquote_fence_produces_no_span() { // Unterminated fence inside a blockquote ⇒ no span. let src = "> ```mermaid\n> flowchart TD\n"; for pretty in [true, false] { let (_, cbs) = blocks(src, pretty); assert!(cbs.is_empty(), "pretty={pretty} got {cbs:?}"); } } #[test] fn indented_code_block_is_not_a_fence() { // A 4-space indented code block is not a fenced block ⇒ no span, even // when its literal content looks like a fence. let src = "para\n\n ```mermaid\n A-->B\n ```\n"; for pretty in [true, false] { let (_, cbs) = blocks(src, pretty); assert!(cbs.is_empty(), "pretty={pretty} got {cbs:?}"); } } #[test] fn empty_body_closed_fence() { let src = "```mermaid\n```\n"; // Pretty: both fence lines are hidden ⇒ no output lines ⇒ the empty // anchor lands at 0..0 (exact, not merely is_empty()). let (_, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(cbs[0].info, "mermaid"); assert_eq!(cbs[0].output_line_range, 0..0); assert_eq!(body_source(src, &cbs[0]), ""); assert_eq!(cbs[0].body, ""); // Raw: both fence lines are shown ⇒ the empty body is anchored between // them at 1..1. let (_, cbs_raw) = blocks(src, false); assert_eq!(cbs_raw.len(), 1); assert_eq!(cbs_raw[0].output_line_range, 1..1); assert_eq!(cbs_raw[0].body, ""); } #[test] fn fence_without_info_string() { let src = "```\nplain code\n```\n"; let (lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(cbs[0].info, ""); assert_eq!(body_lines(&lines, &cbs[0]), vec!["plain code"]); } #[test] fn tilde_fence_and_no_trailing_newline() { // Tilde delimiters and a closed fence at EOF without a final newline. for src in ["~~~text\nA-->B\n~~~\n", "```text\nfoo\n```"] { let (lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1, "{src:?}"); assert_eq!(cbs[0].info, "text"); assert_eq!(body_lines(&lines, &cbs[0]).len(), 1); } } #[test] fn blank_line_inside_body_is_counted() { let src = "```text\nfoo\n\nbar\n```\n"; let (lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(body_lines(&lines, &cbs[0]), vec!["foo", "", "bar"]); assert_eq!(body_source(src, &cbs[0]), "foo\n\nbar\n"); assert_eq!(cbs[0].body, "foo\n\nbar\n"); } #[test] fn crlf_body_is_normalized_but_byte_range_retains_cr() { // CRLF: pulldown normalizes the body content to `\n`, while the raw // byte range still slices the `\r`. Line counting (over `\n`) is // unaffected. let src = "```text\r\nA-->B\r\n```\r\n"; let (lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(cbs[0].body, "A-->B\n"); assert_eq!(body_source(src, &cbs[0]), "A-->B\r\n"); // One rendered body line (the renderer keeps the raw `\r`; `body` is the // normalized source of truth). assert_eq!(body_lines(&lines, &cbs[0]).len(), 1); } #[test] fn multibyte_body_slices_safely() { // Multi-byte UTF-8 in the body: `source_byte_range` must land on char // boundaries (no slice panic) and `body` is the exact content. let src = "```text\nA --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n```\n"; let (lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!( cbs[0].body, "A --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n" ); assert_eq!( body_source(src, &cbs[0]), "A --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n", ); assert_eq!(body_lines(&lines, &cbs[0]).len(), 2); } #[test] fn long_fence_with_inner_backticks() { // A 4-backtick fence whose body contains a ``` line must not close // early — one span whose body includes the inner fence text. let src = "````mermaid\n```\ninner\n```\n````\n"; let (_lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(cbs[0].info, "mermaid"); assert_eq!(cbs[0].body, "```\ninner\n```\n"); } #[test] fn tab_in_body_is_preserved_by_crate() { // The markdown crate preserves a literal tab in the body (the pager // expands tabs before rendering; the crate itself does not). let src = "```mermaid\n\tA --> B\n```\n"; let (_lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(cbs[0].body, "\tA --> B\n"); } #[test] fn rendered_body_lines_match_span_body() { // The rendered (pre-wrap) body lines reconstruct to the span body // (sans the trailing newline a line-join drops). Both pretty and raw. let src = "```text\nflowchart TD\n A --> B\n B --> C\n```\n"; for pretty in [true, false] { let (lines, cbs) = blocks(src, pretty); let joined = body_lines(&lines, &cbs[0]).join("\n"); assert_eq!( joined, "flowchart TD\n A --> B\n B --> C", "pretty={pretty}" ); assert_eq!(format!("{joined}\n"), cbs[0].body, "pretty={pretty}"); } } #[test] fn mermaid_fence_renders_inline_but_span_keeps_clean_source() { // A closed ```mermaid fence is rendered inline by the markdown crate // (its body lines are replaced with diagram art), yet its CodeBlockSpan // still exposes the clean SOURCE via `body` — what the pager feeds the // PNG engine — and an `output_line_range` that spans the rendered // diagram, where the pager anchors its affordance row. This contract is // what the pager's Mermaid affordance row relies on. let src = "```mermaid\nflowchart TD\n A --> B\n```\n"; let (lines, cbs) = blocks(src, true); assert_eq!(cbs.len(), 1); assert_eq!(cbs[0].info, "mermaid"); // `body` is the verbatim diagram source, independent of rendering. assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n"); // The fence is rendered inline: the spanned output lines are the diagram // art, not the verbatim source. assert!(!cbs[0].output_line_range.is_empty()); let rendered = body_lines(&lines, &cbs[0]).join("\n"); assert_ne!(rendered, "flowchart TD\n A --> B"); } #[test] fn streaming_span_stable_once_frozen() { let full = "Intro\n\n```mermaid\nA-->B\nC-->D\n```\n\nAfter the block.\n\nMore prose.\n"; let mut renderer = StreamingMarkdownRenderer::new(STYLE, true); let mut frozen_span: Option = None; for ch in full.chars() { renderer.push_and_render(&ch.to_string(), None); let view = renderer.view(); let frozen_lines = renderer.frozen_lines_count(); if let Some(cb) = view.code_blocks.iter().find(|c| c.info == "mermaid") { // Only assert stability once the block is within frozen content. if cb.output_line_range.end <= frozen_lines { match &frozen_span { None => frozen_span = Some(cb.clone()), Some(prev) => { assert_eq!(prev, cb, "frozen mermaid span changed across pushes",) } } } } } let frozen_span = frozen_span.expect("mermaid block should freeze mid-stream"); // Streaming output (after finish) must match a one-shot full render. let finished = renderer.finish(None); let streamed = finished .code_blocks .iter() .find(|c| c.info == "mermaid") .expect("finish() keeps the mermaid span"); assert_eq!(&frozen_span, streamed); let (full_out, _) = render_markdown_ratatui_full(full, STYLE, true, None); let full_cb = full_out .code_blocks .iter() .find(|c| c.info == "mermaid") .expect("full render finds the mermaid span"); assert_eq!(&frozen_span, full_cb); } #[test] fn streaming_open_fence_has_no_frozen_span() { // While the fence is still open, any transient span must remain in the // unfrozen tail (never within the frozen prefix). let mut renderer = StreamingMarkdownRenderer::new(STYLE, true); for chunk in ["intro\n\n", "```mermaid\n", "flowchart TD\n", "A --> B\n"] { renderer.push_and_render(chunk, None); let frozen_lines = renderer.frozen_lines_count(); let view = renderer.view(); for cb in view.code_blocks { assert!( cb.output_line_range.end > frozen_lines, "open fence span must not be frozen: {cb:?} frozen_lines={frozen_lines}", ); } } } #[test] fn streaming_chunked_matches_full_render() { let full = "# Title\n\n```mermaid\nA-->B\n```\n\ntext\n\n```rust\nfn f() {}\n```\n\nbye\n"; for pretty in [true, false] { let mut renderer = StreamingMarkdownRenderer::new(STYLE, pretty); // Irregular chunk boundaries to exercise tail rebasing. let bytes = full.as_bytes(); let mut pos = 0; for step in [4usize, 9, 1, 13, 7].iter().cycle() { if pos >= bytes.len() { break; } let mut end = (pos + step).min(bytes.len()); while end < bytes.len() && !full.is_char_boundary(end) { end += 1; } renderer.push_and_render(&full[pos..end], None); pos = end; } let view = renderer.finish(None); let streamed: Vec<_> = view.code_blocks.to_vec(); let (full_out, _) = render_markdown_ratatui_full(full, STYLE, pretty, None); assert_eq!( streamed, full_out.code_blocks, "pretty={pretty}: chunked stream must match full render", ); } } }