Skip to main content

kimun_notes/components/text_editor/markdown/
mod.rs

1use crate::settings::themes::Theme;
2use pulldown_cmark::{HeadingLevel, Options, Tag};
3use ratatui::style::{Modifier, Style};
4#[cfg(test)]
5use ratatui::text::Span;
6use unicode_segmentation::UnicodeSegmentation;
7
8mod block_opener;
9mod detect;
10mod parsed_buffer;
11mod spanner;
12pub(super) use block_opener::opener_shape;
13pub use parsed_buffer::ParsedBuffer;
14pub use spanner::MarkdownSpanner;
15
16/// Shared parser options used by all pulldown-cmark call sites in this module.
17pub(super) const PARSER_OPTIONS: Options = Options::ENABLE_STRIKETHROUGH;
18
19/// Visual columns per tab stop, for everything that draws a tab: the renderer's
20/// expansion, the **code box** sizing via [`raw_display_width`], and nvim's own
21/// `tabstop` (set from here in `backend.rs`).
22///
23/// Derived, not declared. The **Layout** measures a tab while wrapping and the
24/// renderer measures it while painting; the two must agree to the cell or every
25/// column past a tab is wrong by the difference, so there is one number and it
26/// lives with the engine that wraps. Writing `4` here again would agree only by
27/// luck, and nothing would catch the luck running out.
28pub(super) const TAB_STOP: usize = crate::ropetext::Metrics::DEFAULT_TAB_WIDTH;
29
30/// Compute the display width of a tab character at the given visual column.
31pub(super) fn tab_width_at(col: usize) -> usize {
32    TAB_STOP - (col % TAB_STOP)
33}
34
35/// Sum of grapheme-cluster display widths across a string. Used to size
36/// synthetic spans (e.g. image-link placeholders) injected during render.
37pub(super) fn string_display_width(s: &str) -> usize {
38    s.graphemes(true).map(cluster_display_width).sum()
39}
40
41/// The blockquote bar gutter drawn in place of the hidden `>` markers: one
42/// `│` per nesting level followed by a single space. This is the single source
43/// of the gutter's shape — `render_with` draws this string, while wrap
44/// reservation and click mapping size themselves via [`blockquote_gutter_width`].
45/// The unit test `gutter_width_matches_rendered` locks the two in sync.
46pub(super) fn blockquote_gutter(depth: u8) -> String {
47    let mut s = "│".repeat(depth as usize);
48    s.push(' ');
49    s
50}
51
52/// Display-column width of [`blockquote_gutter`] for `depth` (`│`×depth + space,
53/// each one column → `depth + 1`). Used by the view to reserve the wrap inset and
54/// to offset click/selection columns.
55pub(super) fn blockquote_gutter_width(depth: u8) -> usize {
56    depth as usize + 1
57}
58
59/// Display-column width of a raw line with all clusters visible and tabs
60/// expanded to the next tab stop. Mirrors the per-cluster column math in
61/// `spanner::render_with` (tab handling + `cluster_display_width`).
62pub(super) fn raw_display_width(line: &str) -> usize {
63    let mut col = 0usize;
64    for g in line.graphemes(true) {
65        col += cluster_width_at(g, col);
66    }
67    col
68}
69
70/// Display width a grapheme cluster occupies when rendered at visual column
71/// `col`: a tab advances to the next tab stop, anything else is its intrinsic
72/// width. Single source of the tab-vs-cluster rule shared by `raw_display_width`
73/// and the force-raw render/cursor paths in `spanner`.
74pub(super) fn cluster_width_at(cluster: &str, col: usize) -> usize {
75    if cluster == "\t" {
76        tab_width_at(col)
77    } else {
78        cluster_display_width(cluster)
79    }
80}
81
82/// Display width of a grapheme cluster.
83///
84/// Measures the whole cluster via [`unicode_width::UnicodeWidthStr`], so emoji presentation
85/// sequences match what terminals draw: a flag (🇪🇸, two regional indicators),
86/// a VS16 sequence (❤️ = U+2764 + U+FE0F), and a keycap (1️⃣) all render as 2
87/// columns even though their first codepoint is narrow. ZWJ sequences (👨‍👩‍👧‍👦)
88/// collapse to a single 2-column glyph and combining marks (e + U+0301)
89/// contribute 0, both of which `width()` already reports correctly.
90///
91/// Genuinely zero-width clusters (ZWSP, soft hyphen, BOM, a lone combining
92/// mark) measure 0 — matching what the terminal draws. The wrap loop's
93/// forward-progress guard in the layout's row wrapping handles a zero-width
94/// start cluster, so no `.max(1)` floor is needed here.
95pub(super) fn cluster_display_width(cluster: &str) -> usize {
96    use unicode_width::UnicodeWidthStr;
97    cluster.width()
98}
99
100#[derive(Debug, Clone, PartialEq)]
101pub struct Element {
102    pub start_char: usize,
103    pub end_char: usize,
104    pub kind: ElementKind,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum ElementKind {
109    Bold,
110    Italic,
111    Strikethrough,
112    InlineCode,
113    Link,
114    HeadingH1,
115    HeadingH2,
116    HeadingH3,
117    Blockquote,
118    WikiLink,
119    Image,
120    Label,
121}
122
123/// A single image-link span on a parsed line, replaced visually with a
124/// placeholder when rendering. `start_char`..`end_char` covers the full
125/// `![alt](url)` source range. `placeholder_width` is precomputed so the
126/// per-render hot path does not re-walk the placeholder graphemes.
127#[derive(Debug, Clone)]
128pub struct ImagePlaceholder {
129    pub start_char: usize,
130    pub end_char: usize,
131    pub placeholder: String,
132    pub placeholder_width: usize,
133}
134
135/// Pre-parsed result for a single logical line.
136/// Build once per frame via `ParsedLine::parse`, then reuse across render, cursor,
137/// wrap-width, and click-mapping calls to avoid redundant pulldown-cmark invocations.
138#[derive(Debug, Clone)]
139pub struct ParsedLine {
140    pub elements: Vec<Element>,
141    /// Per-char visibility: `true` = this char is rendered content (not a markdown sigil).
142    pub content_vis: Vec<bool>,
143    /// Per-char: `true` = this char falls within any element's char range.
144    /// Enables O(1) `in_any_element` without iterating `elements`.
145    elem_vis: Vec<bool>,
146    /// Per-char element index, 1-based (0 = no element). Enables O(1) `elem_at`.
147    /// Stored as `u16`; supports up to 65535 elements per line.
148    elem_index: Vec<u16>,
149    /// Per-char text-modifier mask (`MOD_BOLD` / `MOD_ITALIC` / `MOD_STRIKE`)
150    /// OR-ed from *every* element covering the char, so an inner Link/WikiLink
151    /// nested inside `**…**` / `*…*` still renders bold/italic. `elem_index`
152    /// only tracks the innermost element (for fg/underline), which would
153    /// otherwise drop the outer emphasis modifiers.
154    modifier_mask: Vec<u8>,
155    /// Char offset where the list-item sigil (indent + marker + space) ends on
156    /// this line, or `None` if this line is not the first line of a list item.
157    list_sigil_end: Option<usize>,
158    /// Image-link spans on this line, sorted by `start_char`. Their underlying
159    /// chars are hidden (`content_vis = false`) and replaced visually by
160    /// `placeholder` when rendering.
161    pub image_placeholders: Vec<ImagePlaceholder>,
162    /// Blockquote nesting depth (number of Blockquote elements covering this
163    /// line), or `None` if this line is not part of a blockquote. Derived from
164    /// pulldown's structure so lazy-continuation lines (quote text with no `>`
165    /// prefix) and nested quotes report the correct depth. Set by
166    /// `ParsedBuffer::parse`.
167    blockquote_depth: Option<u8>,
168}
169
170impl ParsedLine {
171    /// Parse a single line in isolation. Internally delegates to
172    /// `ParsedBuffer::parse`; kept for test convenience.
173    ///
174    /// When the line looks like an indented list item (e.g. `    - foo` or
175    /// `\t- foo`), pulldown-cmark treats it as an indented code block rather
176    /// than a list item on its own. To preserve the real-editor behaviour
177    /// (where context from surrounding lines resolves it as a nested list
178    /// item), prepend a synthetic parent list marker before handing the input
179    /// to `ParsedBuffer::parse` and return the result for the original line.
180    pub fn parse(line: &str) -> Self {
181        let owned = line.to_string();
182        if needs_synthetic_list_parent(line) {
183            // "- " opens a list at column 0; the indented `line` that follows
184            // becomes a nested list item with full context.
185            ParsedBuffer::parse_lines(&["- ".to_string(), owned])
186                .lines
187                .pop()
188                .expect("ParsedBuffer::parse returns one row per input line")
189        } else {
190            ParsedBuffer::parse_lines(std::slice::from_ref(&owned))
191                .lines
192                .pop()
193                .expect("ParsedBuffer::parse always returns at least one ParsedLine")
194        }
195    }
196
197    /// Element index at `pos`, or `None`. O(1) via precomputed `elem_index`.
198    pub fn elem_at(&self, pos: usize) -> Option<usize> {
199        self.elem_index.get(pos).and_then(|&tag| {
200            if tag == 0 {
201                None
202            } else {
203                Some((tag as usize) - 1)
204            }
205        })
206    }
207
208    /// Whether `pos` falls inside any tracked element. O(1) via precomputed `elem_vis`.
209    pub fn in_any_element(&self, pos: usize) -> bool {
210        self.elem_vis.get(pos).copied().unwrap_or(false)
211    }
212
213    /// Combined emphasis-modifier mask (`MOD_*`) for the char at `pos`, OR-ed
214    /// across all covering elements. `0` outside any emphasis element.
215    pub(super) fn modifiers_at(&self, pos: usize) -> u8 {
216        self.modifier_mask.get(pos).copied().unwrap_or(0)
217    }
218
219    /// Returns the char offset of the first *content* char inside a heading element
220    /// (i.e. the end of the "# " / "## " / "### " sigil region), or `None` if this
221    /// line has no heading element.
222    ///
223    /// Defaults to `e.end_char` so that a heading with no content text (e.g. `"#"`) is
224    /// fully treated as sigil — fixes the F-02 bug where `e.start_char` was used.
225    pub fn heading_sigil_end(&self) -> Option<usize> {
226        self.elements
227            .iter()
228            .position(|e| {
229                matches!(
230                    e.kind,
231                    ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
232                )
233            })
234            .map(|idx| self.first_content_char(idx))
235    }
236
237    /// Char offset of the first content (non-sigil) char inside element
238    /// `elem_idx`, or its `end_char` when the element is all sigil (e.g. a bare
239    /// `#` / `>`). Shared by `heading_sigil_end` and `blockquote_sigil_end`.
240    ///
241    /// A hidden char (`content_vis == false`) that belongs to a *different*
242    /// element — e.g. the `[` of a `[link](url)` or the `*` of `**bold**`
243    /// immediately after `# ` — also terminates the sigil: its sigils belong to
244    /// the inner element, not the heading/blockquote marker, and are hidden by
245    /// that element's own render path.
246    fn first_content_char(&self, elem_idx: usize) -> usize {
247        let e = &self.elements[elem_idx];
248        for i in e.start_char..e.end_char {
249            if i < self.content_vis.len() && self.content_vis[i] {
250                return i;
251            }
252            if self.elem_at(i).is_some_and(|inner| inner != elem_idx) {
253                return i;
254            }
255        }
256        e.end_char
257    }
258
259    /// Char offset where the list-item sigil ends on this line, or `None` if this
260    /// line is not the first line of a list item.
261    pub fn list_sigil_end(&self) -> Option<usize> {
262        self.list_sigil_end
263    }
264
265    /// Blockquote nesting depth for this line, or `None` if not a blockquote.
266    pub fn blockquote_depth(&self) -> Option<u8> {
267        self.blockquote_depth
268    }
269
270    /// Char offset where the blockquote marker region (`>`/spaces) ends, i.e.
271    /// the first content char. `None` if this line is not part of a blockquote.
272    /// `blockquote_depth` is `Some` iff a Blockquote element exists (both are
273    /// element-derived), so the `find` always matches when this returns a value.
274    pub fn blockquote_sigil_end(&self) -> Option<usize> {
275        self.elements
276            .iter()
277            .position(|e| e.kind == ElementKind::Blockquote)
278            .map(|idx| self.first_content_char(idx))
279    }
280
281    /// Diagnostic helper: compare every field for byte-identity. Used by
282    /// the view's debug-only correctness assertion. Returns Ok(()) when
283    /// all fields match, Err with a human-readable message describing the
284    /// first divergence.
285    #[cfg(debug_assertions)]
286    pub(super) fn debug_assert_eq_to(&self, other: &Self, row: usize) {
287        assert_eq!(
288            self.content_vis, other.content_vis,
289            "row {row} content_vis diverge"
290        );
291        assert_eq!(self.elem_vis, other.elem_vis, "row {row} elem_vis diverge");
292        assert_eq!(
293            self.elem_index, other.elem_index,
294            "row {row} elem_index diverge"
295        );
296        assert_eq!(
297            self.list_sigil_end, other.list_sigil_end,
298            "row {row} list_sigil_end diverge"
299        );
300        assert_eq!(
301            self.blockquote_depth, other.blockquote_depth,
302            "row {row} blockquote_depth diverge"
303        );
304        assert_eq!(
305            self.elements.len(),
306            other.elements.len(),
307            "row {row} elements.len() diverge"
308        );
309    }
310}
311
312/// Detects whether a line is an indented list item (leading spaces or tab,
313/// followed by `-`/`*`/`+`/digit-dot + space). Used by `ParsedLine::parse`
314/// to decide whether to feed pulldown-cmark a synthetic parent-list context
315/// for single-line degenerate inputs.
316fn needs_synthetic_list_parent(line: &str) -> bool {
317    let trimmed = line.trim_start_matches([' ', '\t']);
318    if trimmed.len() == line.len() {
319        return false; // no leading whitespace → nothing to compensate for
320    }
321    list_marker_len(trimmed).is_some()
322}
323
324/// If the string begins with an unordered list marker (`- `, `* `, `+ `) or an
325/// ordered list marker (digits followed by `. `), returns the marker's length
326/// in bytes (including the trailing space). Otherwise `None`.
327///
328/// Digits are ASCII only, so byte length == char length here.
329/// Byte length of the leading run of ASCII space/tab characters in `line`.
330/// Equal to the char count for that run (whitespace is ASCII).
331pub(super) fn leading_ws_byte_len(line: &str) -> usize {
332    line.bytes()
333        .take_while(|b| *b == b' ' || *b == b'\t')
334        .count()
335}
336
337/// Maps a pulldown-cmark start `Tag` to its corresponding `ElementKind`, for
338/// the tags whose end events emit a stacked element via the standard
339/// push-on-start / pop-on-end pattern. Tags handled specially (e.g. `Item`,
340/// `Code`) return `None`.
341pub(super) fn tag_to_kind(tag: &Tag) -> Option<ElementKind> {
342    Some(match tag {
343        Tag::Strong => ElementKind::Bold,
344        Tag::Emphasis => ElementKind::Italic,
345        Tag::Strikethrough => ElementKind::Strikethrough,
346        Tag::Link { .. } => ElementKind::Link,
347        Tag::BlockQuote(_) => ElementKind::Blockquote,
348        Tag::Heading { level, .. } => match level {
349            HeadingLevel::H1 => ElementKind::HeadingH1,
350            HeadingLevel::H2 => ElementKind::HeadingH2,
351            _ => ElementKind::HeadingH3,
352        },
353        _ => return None,
354    })
355}
356
357pub(super) fn list_marker_len(s: &str) -> Option<usize> {
358    if s.starts_with("- ") || s.starts_with("* ") || s.starts_with("+ ") {
359        return Some(2);
360    }
361    let bytes = s.as_bytes();
362    let mut i = 0;
363    while i < bytes.len() && bytes[i].is_ascii_digit() {
364        i += 1;
365    }
366    if i > 0 && i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b' ' {
367        Some(i + 2)
368    } else {
369        None
370    }
371}
372
373/// Per-char emphasis-modifier bits, OR-ed across nested elements.
374pub(super) const MOD_BOLD: u8 = 1 << 0;
375pub(super) const MOD_ITALIC: u8 = 1 << 1;
376pub(super) const MOD_STRIKE: u8 = 1 << 2;
377
378/// Emphasis bit contributed by an element kind, or `0` for non-emphasis kinds.
379pub(super) fn modifier_bit(kind: ElementKind) -> u8 {
380    match kind {
381        ElementKind::Bold => MOD_BOLD,
382        ElementKind::Italic => MOD_ITALIC,
383        ElementKind::Strikethrough => MOD_STRIKE,
384        _ => 0,
385    }
386}
387
388/// Translates a `MOD_*` mask into ratatui [`Modifier`] flags.
389pub(super) fn mask_to_modifier(mask: u8) -> Modifier {
390    let mut m = Modifier::empty();
391    if mask & MOD_BOLD != 0 {
392        m |= Modifier::BOLD;
393    }
394    if mask & MOD_ITALIC != 0 {
395        m |= Modifier::ITALIC;
396    }
397    if mask & MOD_STRIKE != 0 {
398        m |= Modifier::CROSSED_OUT;
399    }
400    m
401}
402
403pub(super) fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
404    match kind {
405        None => {
406            if is_sigil_region {
407                Style::default().fg(theme.gray.to_ratatui())
408            } else {
409                Style::default().fg(theme.fg.to_ratatui())
410            }
411        }
412        Some(ElementKind::Bold) => Style::default()
413            .fg(theme.accent.to_ratatui())
414            .add_modifier(Modifier::BOLD),
415        Some(ElementKind::Italic) => Style::default()
416            .fg(theme.fg_secondary.to_ratatui())
417            .add_modifier(Modifier::ITALIC),
418        Some(ElementKind::Strikethrough) => Style::default()
419            .fg(theme.fg_secondary.to_ratatui())
420            .add_modifier(Modifier::CROSSED_OUT),
421        Some(ElementKind::InlineCode) => Style::default()
422            .fg(theme.aqua.to_ratatui())
423            .bg(theme.bg_soft.to_ratatui()),
424        Some(ElementKind::Link) => Style::default()
425            .fg(theme.accent.to_ratatui())
426            .add_modifier(Modifier::UNDERLINED),
427        Some(ElementKind::Image) => Style::default()
428            .fg(theme.accent.to_ratatui())
429            .add_modifier(Modifier::ITALIC),
430        // Spec §5.1: H1/H2 bright + bold, H3 yellow + bold.
431        Some(ElementKind::HeadingH1) | Some(ElementKind::HeadingH2) => {
432            if is_sigil_region {
433                Style::default().fg(theme.gray.to_ratatui())
434            } else {
435                Style::default()
436                    .fg(theme.fg_bright.to_ratatui())
437                    .add_modifier(Modifier::BOLD)
438            }
439        }
440        Some(ElementKind::HeadingH3) => {
441            if is_sigil_region {
442                Style::default().fg(theme.gray.to_ratatui())
443            } else {
444                Style::default()
445                    .fg(theme.yellow.to_ratatui())
446                    .add_modifier(Modifier::BOLD)
447            }
448        }
449        Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
450        // Spec §5.1: wikilink targets are blue + underlined.
451        Some(ElementKind::WikiLink) => Style::default()
452            .fg(theme.blue.to_ratatui())
453            .add_modifier(Modifier::UNDERLINED),
454        Some(ElementKind::Label) => Style::default()
455            .fg(theme.color_tag.to_ratatui())
456            .add_modifier(Modifier::BOLD),
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::super::parse_incremental::LineConstructKind;
463    use super::*;
464    use ratatui::style::Modifier;
465    fn t() -> Theme {
466        Theme::default()
467    }
468
469    /// The renderer expands a tab; the **Layout** measures one while wrapping.
470    /// They must land on the same stop, so this pins the derivation rather than
471    /// two constants that happen to match. Both directions, since a caller could
472    /// pass a non-default `Metrics` and only the default is what the renderer
473    /// tracks.
474    #[test]
475    fn the_renderer_measures_a_tab_exactly_as_the_engine_does() {
476        let metrics = crate::ropetext::Metrics::default();
477        assert_eq!(TAB_STOP, metrics.tab_width);
478        for col in 0..(TAB_STOP * 3) {
479            assert_eq!(
480                tab_width_at(col),
481                metrics.width_at("\t", col),
482                "a tab drawn at column {col}"
483            );
484        }
485    }
486    fn text(spans: &[Span]) -> String {
487        spans.iter().map(|s| s.content.as_ref()).collect()
488    }
489
490    #[test]
491    fn cluster_display_width_emoji_presentation_sequences() {
492        // These grapheme clusters render as 2 columns in modern terminals, but
493        // their FIRST codepoint is narrow (width 1). Measuring only the first
494        // codepoint undercounts them — wrap, cursor, and click math then drift.
495        assert_eq!(cluster_display_width("\u{1F1EA}\u{1F1F8}"), 2, "flag 🇪🇸");
496        assert_eq!(
497            cluster_display_width("\u{2764}\u{FE0F}"),
498            2,
499            "heart ❤️ (VS16)"
500        );
501        assert_eq!(cluster_display_width("1\u{FE0F}\u{20E3}"), 2, "keycap 1️⃣");
502        // Sanity: clusters already correct must stay correct.
503        assert_eq!(cluster_display_width("\u{3042}"), 2, "CJK あ");
504        assert_eq!(cluster_display_width("a"), 1, "ascii");
505        assert_eq!(cluster_display_width("e\u{0301}"), 1, "e + combining acute");
506    }
507
508    #[test]
509    fn cluster_display_width_zero_width_clusters_are_zero() {
510        // Genuinely zero-width clusters render as 0 columns in terminals. Counting
511        // them as 1 drifts wrap, cursor, and selection math by one column each.
512        assert_eq!(cluster_display_width("\u{200B}"), 0, "ZWSP");
513        assert_eq!(cluster_display_width("\u{00AD}"), 0, "soft hyphen");
514        assert_eq!(cluster_display_width("\u{200C}"), 0, "ZWNJ");
515        assert_eq!(cluster_display_width("\u{FEFF}"), 0, "BOM");
516        assert_eq!(cluster_display_width("\u{0301}"), 0, "lone combining acute");
517    }
518
519    #[test]
520    fn blockquote_lazy_continuation_carries_depth() {
521        // A line with no leading `>` that lazily continues a blockquote
522        // (CommonMark §5.1) is part of the quote: pulldown spans the
523        // Blockquote element across it, so it must report the quote's depth
524        // and get the bar gutter — not just the quoted text color.
525        let buf = ParsedBuffer::parse_lines(&["> first".to_string(), "second".to_string()]);
526        assert_eq!(buf.lines[0].blockquote_depth(), Some(1));
527        assert_eq!(
528            buf.lines[1].blockquote_depth(),
529            Some(1),
530            "lazy continuation line must carry the blockquote depth"
531        );
532
533        // Nested lazy continuation keeps the full depth.
534        let nested = ParsedBuffer::parse_lines(&[">> a".to_string(), "b".to_string()]);
535        assert_eq!(nested.lines[0].blockquote_depth(), Some(2));
536        assert_eq!(nested.lines[1].blockquote_depth(), Some(2));
537
538        // A blank line ends the quote: the following line is NOT a continuation.
539        let ended =
540            ParsedBuffer::parse_lines(&["> first".to_string(), String::new(), "plain".to_string()]);
541        assert_eq!(ended.lines[0].blockquote_depth(), Some(1));
542        assert_eq!(ended.lines[2].blockquote_depth(), None);
543    }
544
545    #[test]
546    fn indented_code_excludes_trailing_blank_keeps_interior() {
547        use super::super::parse_incremental::LineConstructKind::{Blank, IndentedCode, Plain};
548        let kinds = |lines: &[&str]| {
549            let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
550            ParsedBuffer::parse_lines(&owned).kinds
551        };
552        // Trailing blank after indented code is NOT part of the block.
553        assert_eq!(
554            kinds(&["    code", "", "outro"]),
555            vec![IndentedCode, Blank, Plain]
556        );
557        // Interior blank (between indented lines) stays part of the block.
558        assert_eq!(
559            kinds(&["    a", "", "    b"]),
560            vec![IndentedCode, IndentedCode, IndentedCode]
561        );
562        // Multiple trailing blanks all revert to Blank.
563        assert_eq!(
564            kinds(&["    a", "", "", "outro"]),
565            vec![IndentedCode, Blank, Blank, Plain]
566        );
567        // A less-indented content line (no blank before it) ends the block —
568        // it is NOT swallowed into the code block.
569        assert_eq!(
570            kinds(&["    Line 1", "    Line 2", "Line 3"]),
571            vec![IndentedCode, IndentedCode, Plain]
572        );
573        // Interior blanks (any number) between indented chunks stay in-block.
574        assert_eq!(
575            kinds(&["    line 1", "    line 2", "", "", "    line 3"]),
576            vec![
577                IndentedCode,
578                IndentedCode,
579                IndentedCode,
580                IndentedCode,
581                IndentedCode
582            ]
583        );
584    }
585
586    #[test]
587    fn gutter_width_matches_rendered() {
588        // Locks the single-source contract: blockquote_gutter_width must equal
589        // the display width of the actually-rendered blockquote_gutter string.
590        for d in 1u8..=4 {
591            assert_eq!(
592                blockquote_gutter_width(d),
593                string_display_width(&blockquote_gutter(d)),
594                "gutter width/string disagree at depth {d}"
595            );
596        }
597    }
598
599    #[test]
600    fn blockquote_depth_and_sigil_end() {
601        let p = ParsedLine::parse("> hello");
602        assert_eq!(p.blockquote_depth(), Some(1));
603        // sigil region is "> " (2 chars); content starts at index 2.
604        assert_eq!(p.blockquote_sigil_end(), Some(2));
605
606        let p2 = ParsedLine::parse(">> deep");
607        assert_eq!(p2.blockquote_depth(), Some(2));
608
609        let plain = ParsedLine::parse("not a quote");
610        assert_eq!(plain.blockquote_depth(), None);
611        assert_eq!(plain.blockquote_sigil_end(), None);
612    }
613    #[test]
614    fn parse_bold_range() {
615        let e = MarkdownSpanner::parse_elements("**bold**");
616        let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
617        assert_eq!((b.start_char, b.end_char), (0, 8));
618    }
619    #[test]
620    fn parse_italic() {
621        assert!(
622            MarkdownSpanner::parse_elements("*hi*")
623                .iter()
624                .any(|e| e.kind == ElementKind::Italic)
625        );
626    }
627    #[test]
628    fn parse_strikethrough() {
629        let e = MarkdownSpanner::parse_elements("~~gone~~");
630        let s = e
631            .iter()
632            .find(|x| x.kind == ElementKind::Strikethrough)
633            .unwrap();
634        assert_eq!((s.start_char, s.end_char), (0, 8));
635    }
636    #[test]
637    fn strikethrough_renders_with_crossed_out_modifier() {
638        let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
639        assert_eq!(text(&s), "gone");
640        assert!(
641            s.iter()
642                .any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
643        );
644    }
645    #[test]
646    fn parse_inline_code() {
647        assert!(
648            MarkdownSpanner::parse_elements("`x`")
649                .iter()
650                .any(|e| e.kind == ElementKind::InlineCode)
651        );
652    }
653    #[test]
654    fn parse_link() {
655        assert!(
656            MarkdownSpanner::parse_elements("[t](u)")
657                .iter()
658                .any(|e| e.kind == ElementKind::Link)
659        );
660    }
661
662    #[test]
663    fn parse_image_emits_image_element_and_placeholder() {
664        let line = "see ![alt](../assets/img.png) here";
665        let parsed = ParsedLine::parse(line);
666        let img = parsed
667            .elements
668            .iter()
669            .find(|e| e.kind == ElementKind::Image)
670            .expect("image element");
671        assert_eq!(line.chars().nth(img.start_char), Some('!'));
672        assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
673        let ph = parsed
674            .image_placeholders
675            .iter()
676            .find(|p| p.start_char == img.start_char)
677            .expect("placeholder for image");
678        assert_eq!(ph.placeholder, "[img.png]");
679        for pos in img.start_char..img.end_char {
680            assert!(
681                !parsed.content_vis[pos],
682                "char {pos} should be hidden inside image span"
683            );
684        }
685    }
686
687    #[test]
688    fn render_image_substitutes_placeholder_text() {
689        let line = "before ![alt](pic.gif) after";
690        let parsed = ParsedLine::parse(line);
691        let spans =
692            MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
693        let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
694        assert!(
695            rendered.contains("[pic.gif]"),
696            "rendered text {rendered:?} should include placeholder"
697        );
698        assert!(
699            !rendered.contains("![alt]"),
700            "raw image syntax should not appear in rendered output: {rendered:?}"
701        );
702    }
703
704    #[test]
705    fn render_image_with_empty_alt_uses_filename() {
706        let line = "![](image.png)";
707        let parsed = ParsedLine::parse(line);
708        let spans =
709            MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
710        let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
711        assert_eq!(rendered, "[image.png]");
712    }
713
714    #[test]
715    fn rendered_cursor_col_accounts_for_placeholder_width() {
716        // "![](x.png)" → placeholder "[x.png]" (7 chars) replaces 10 source chars.
717        let line = "a ![](x.png) b";
718        let parsed = ParsedLine::parse(line);
719        let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
720            line,
721            &parsed,
722            0,
723            "a ![](x.png) b".chars().count(), // cursor at end
724            true,
725            false,
726        );
727        // "a " (2) + "[x.png]" (7) + " b" (2) = 11.
728        assert_eq!(after_placeholder, 11);
729    }
730    #[test]
731    fn parse_h1() {
732        assert!(
733            MarkdownSpanner::parse_elements("# T")
734                .iter()
735                .any(|e| e.kind == ElementKind::HeadingH1)
736        );
737    }
738    #[test]
739    fn parse_h2() {
740        assert!(
741            MarkdownSpanner::parse_elements("## T")
742                .iter()
743                .any(|e| e.kind == ElementKind::HeadingH2)
744        );
745    }
746    #[test]
747    fn parse_h3() {
748        assert!(
749            MarkdownSpanner::parse_elements("### T")
750                .iter()
751                .any(|e| e.kind == ElementKind::HeadingH3)
752        );
753    }
754    #[test]
755    fn force_raw_no_styling() {
756        let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
757        assert_eq!(text(&s), "**x**");
758        assert!(
759            !s.iter()
760                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
761        );
762    }
763    #[test]
764    fn plain_text_passthrough() {
765        let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
766        assert_eq!(text(&s), "hi");
767    }
768    #[test]
769    fn bold_without_cursor_hides_markers() {
770        let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
771        assert_eq!(text(&s), "bold");
772        assert!(
773            s.iter()
774                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
775        );
776    }
777    #[test]
778    fn bold_cursor_inside_shows_raw() {
779        let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
780        assert_eq!(text(&s), "**bold**");
781    }
782    #[test]
783    fn bold_cursor_outside_stays_rendered() {
784        let line = "hello **bold** world";
785        let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
786        assert!(!text(&s).contains("**"));
787    }
788    #[test]
789    fn italic_cursor_inside_shows_raw() {
790        let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
791        assert_eq!(text(&s), "*hi*");
792    }
793    #[test]
794    fn inline_code_hides_backticks() {
795        let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
796        assert_eq!(text(&s), "x");
797    }
798    #[test]
799    fn h1_first_line_contains_hash() {
800        let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
801        assert!(text(&s).contains('#'));
802        assert!(text(&s).contains('T'));
803    }
804    #[test]
805    fn continuation_line_no_hash() {
806        let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
807        assert!(!text(&s).contains('#'));
808    }
809    #[test]
810    fn unordered_list_shows_marker() {
811        let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
812        assert!(
813            text(&s).starts_with("- "),
814            "expected '- item', got '{}'",
815            text(&s)
816        );
817        assert!(text(&s).contains("item"));
818    }
819    #[test]
820    fn ordered_list_shows_marker() {
821        let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
822        assert!(
823            text(&s).starts_with("1. "),
824            "expected '1. item', got '{}'",
825            text(&s)
826        );
827    }
828    #[test]
829    fn nested_list_4space_link_rendered() {
830        // 4-space indent + list marker + markdown link.
831        let line = "    - [my link](url)";
832        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
833        // Link styling must appear (UNDERLINED modifier) and the raw "](url)" sigils
834        // must be hidden.
835        assert!(
836            s.iter()
837                .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
838            "link text should be underlined on a 4-space-indented nested list item"
839        );
840        let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
841        assert!(
842            rendered.contains("my link"),
843            "link display text should be visible; got {:?}",
844            rendered
845        );
846        assert!(
847            !rendered.contains("](url)"),
848            "link URL sigil should be hidden; got {:?}",
849            rendered
850        );
851    }
852
853    #[test]
854    fn nested_list_tab_bold_rendered() {
855        let line = "\t- **bold nested**";
856        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
857        assert!(
858            s.iter()
859                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
860            "bold text should be styled on a tab-indented nested list item"
861        );
862        let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
863        assert!(
864            !rendered.contains("**"),
865            "bold markers should be hidden; got {:?}",
866            rendered
867        );
868    }
869
870    #[test]
871    fn nested_list_4space_wikilink_rendered() {
872        let line = "    - [[Target Note]]";
873        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
874        let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
875        assert!(
876            !rendered.contains("[["),
877            "wikilink brackets should be hidden; got {:?}",
878            rendered
879        );
880        assert!(
881            rendered.contains("Target Note"),
882            "wikilink target text should render; got {:?}",
883            rendered
884        );
885    }
886
887    #[test]
888    fn nested_list_2space_still_renders_link() {
889        // Existing 2-space case — must not regress.
890        let line = "  - [link](url)";
891        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
892        assert!(
893            s.iter()
894                .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
895        );
896    }
897
898    #[test]
899    fn empty_heading_shows_hash_sigil() {
900        let line = "# ";
901        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
902        assert!(
903            text(&s).contains('#'),
904            "hash sigil should render in empty heading"
905        );
906        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
907        assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
908    }
909    #[test]
910    fn empty_heading_hash_only_shows() {
911        let line = "#";
912        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
913        assert!(text(&s).contains('#'));
914        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
915        assert_eq!(col, 1);
916    }
917    #[test]
918    fn heading_trailing_spaces_are_rendered() {
919        let line = "# Hello   ";
920        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
921        assert_eq!(
922            text(&s),
923            "# Hello   ",
924            "trailing spaces in heading should render"
925        );
926    }
927    #[test]
928    fn heading_trailing_spaces_cursor_col_correct() {
929        let line = "# Hello   ";
930        // cursor at logical pos 9 (last trailing space): positions 0..9 all emit → rendered col 9
931        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
932        assert_eq!(
933            col, 9,
934            "cursor in trailing space of heading should map to rendered col 9"
935        );
936    }
937    #[test]
938    fn trailing_spaces_are_rendered() {
939        let line = "hello   ";
940        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
941        assert_eq!(text(&s), "hello   ");
942    }
943    #[test]
944    fn trailing_spaces_cursor_col_correct() {
945        let line = "hello   ";
946        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
947        assert_eq!(col, 7);
948    }
949    #[test]
950    fn list_marker_on_continuation_line_hidden() {
951        let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
952        assert!(!text(&s).starts_with("- "));
953    }
954    #[test]
955    fn parsed_line_heading_sigil_end_empty_heading() {
956        // "#" alone: no content chars, sigil_end should equal e.end_char (1)
957        let p = ParsedLine::parse("#");
958        assert_eq!(p.heading_sigil_end(), Some(1));
959    }
960    #[test]
961    fn parsed_line_heading_sigil_end_with_content() {
962        // "# T": sigil is "# " (2 chars), first content at pos 2
963        let p = ParsedLine::parse("# T");
964        assert_eq!(p.heading_sigil_end(), Some(2));
965    }
966    #[test]
967    fn parsed_line_reuse_matches_individual() {
968        let line = "**hello** world";
969        let parsed = ParsedLine::parse(line);
970        let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
971        let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
972        assert_eq!(
973            s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
974            s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
975        );
976    }
977
978    // ── WikiLink tests ────────────────────────────────────────────────────────
979
980    #[test]
981    fn parse_wikilink() {
982        let e = MarkdownSpanner::parse_elements("[[My Note]]");
983        let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
984        assert_eq!((wl.start_char, wl.end_char), (0, 11));
985    }
986
987    #[test]
988    fn wikilink_without_cursor_hides_brackets() {
989        let line = "[[My Note]]";
990        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
991        assert_eq!(text(&s), "My Note");
992        assert!(
993            s.iter()
994                .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
995        );
996    }
997
998    #[test]
999    fn wikilink_cursor_inside_shows_brackets() {
1000        let line = "[[My Note]]";
1001        // cursor at pos 4 (inside "My Note")
1002        let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
1003        assert_eq!(text(&s), "[[My Note]]");
1004    }
1005
1006    #[test]
1007    fn wikilink_cursor_outside_hides_brackets() {
1008        let line = "hello [[My Note]] world";
1009        let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
1010        assert!(!text(&s).contains("[["));
1011        assert!(!text(&s).contains("]]"));
1012    }
1013
1014    #[test]
1015    fn wikilink_in_heading_rendered() {
1016        let line = "# See [[Topic]]";
1017        let e = MarkdownSpanner::parse_elements(line);
1018        assert!(
1019            e.iter().any(|x| x.kind == ElementKind::WikiLink),
1020            "wikilink inside heading should produce a WikiLink element"
1021        );
1022        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1023        assert_eq!(text(&s), "# See Topic", "wikilink brackets hidden, # kept");
1024    }
1025
1026    #[test]
1027    fn heading_with_link_does_not_leak_bracket() {
1028        let line = "# [text](http://x)";
1029        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1030        // `# ` sigil stays visible (heading marker); link sigils incl. the
1031        // leading `[` are hidden, leaving just the display text.
1032        assert_eq!(text(&s), "# text");
1033    }
1034
1035    #[test]
1036    fn heading_with_bold_does_not_leak_asterisk() {
1037        let line = "# **bold**";
1038        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1039        assert_eq!(
1040            text(&s),
1041            "# bold",
1042            "leading * must not leak into heading sigil"
1043        );
1044    }
1045
1046    #[test]
1047    fn bold_wikilink_is_bold() {
1048        let line = "**[[Topic]]**";
1049        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1050        assert_eq!(text(&s), "Topic");
1051        assert!(
1052            s.iter()
1053                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)
1054                    && sp.content.contains("Topic")),
1055            "wikilink wrapped in ** must render bold"
1056        );
1057    }
1058
1059    #[test]
1060    fn italic_link_is_italic() {
1061        let line = "*[text](http://x)*";
1062        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1063        assert_eq!(text(&s), "text");
1064        assert!(
1065            s.iter()
1066                .any(|sp| sp.style.add_modifier.contains(Modifier::ITALIC)
1067                    && sp.content.contains("text")),
1068            "link wrapped in * must render italic"
1069        );
1070    }
1071
1072    #[test]
1073    fn bold_italic_wikilink_is_bold_and_italic() {
1074        let line = "***[[Topic]]***";
1075        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1076        assert_eq!(text(&s), "Topic");
1077        assert!(
1078            s.iter().any(|sp| {
1079                sp.content.contains("Topic")
1080                    && sp.style.add_modifier.contains(Modifier::BOLD)
1081                    && sp.style.add_modifier.contains(Modifier::ITALIC)
1082            }),
1083            "wikilink in *** *** must render both bold and italic"
1084        );
1085    }
1086
1087    #[test]
1088    fn bold_italic_plain_text() {
1089        let line = "***text***";
1090        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1091        assert_eq!(text(&s), "text");
1092        assert!(
1093            s.iter().any(|sp| {
1094                sp.content.contains("text")
1095                    && sp.style.add_modifier.contains(Modifier::BOLD)
1096                    && sp.style.add_modifier.contains(Modifier::ITALIC)
1097            }),
1098            "*** *** must render both bold and italic"
1099        );
1100    }
1101
1102    #[test]
1103    fn wikilink_mid_sentence() {
1104        let line = "See [[Topic]] for details";
1105        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1106        assert_eq!(text(&s), "See Topic for details");
1107    }
1108
1109    #[test]
1110    fn wikilink_cursor_col_accounts_for_brackets() {
1111        // "[[Hi]]" — cursor at pos 2 ('H') is inside the element, so it expands.
1112        // Rendered col counts pos 0 ('['), pos 1 ('[') as visible (expanded sigils) → col = 2.
1113        let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
1114        assert_eq!(col, 2);
1115
1116        // Cursor outside the wikilink (pos 0 on a plain-text line before it):
1117        // "See [[Hi]] x" with cursor at pos 0 — wikilink not expanded, brackets hidden.
1118        // pos 0 ('S') is plain text, rendered col = 0.
1119        let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
1120        assert_eq!(col2, 0);
1121    }
1122
1123    #[test]
1124    fn buffer_parse_nested_list_under_parent() {
1125        // Canonical nested-list pattern: parent at col 0, child indented 4.
1126        let lines = vec![
1127            "- parent".to_string(),
1128            "    - [child link](url)".to_string(),
1129        ];
1130        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1131        assert_eq!(parsed.len(), 2);
1132
1133        // Parent line: list sigil at col 2.
1134        assert_eq!(parsed[0].list_sigil_end(), Some(2));
1135
1136        // Child line: pulldown-cmark reports the item marker at col 4.
1137        assert_eq!(
1138            parsed[1].list_sigil_end(),
1139            Some(6),
1140            "child's sigil_end should be after '    - ' (6 chars)"
1141        );
1142
1143        // Child line has a Link element.
1144        assert!(
1145            parsed[1]
1146                .elements
1147                .iter()
1148                .any(|e| e.kind == ElementKind::Link),
1149            "nested list item should contain a Link element"
1150        );
1151    }
1152
1153    #[test]
1154    fn buffer_parse_standalone_2space_list_still_works() {
1155        // Regression: 2-space indent works on its own too.
1156        let lines = vec!["  - [link](url)".to_string()];
1157        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1158        assert!(
1159            parsed[0]
1160                .elements
1161                .iter()
1162                .any(|e| e.kind == ElementKind::Link)
1163        );
1164        assert_eq!(parsed[0].list_sigil_end(), Some(4));
1165    }
1166
1167    #[test]
1168    fn buffer_parse_top_level_unchanged() {
1169        // Ensure nothing about top-level rendering changed.
1170        let lines = vec!["- [link](url)".to_string()];
1171        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1172        assert!(
1173            parsed[0]
1174                .elements
1175                .iter()
1176                .any(|e| e.kind == ElementKind::Link)
1177        );
1178        assert_eq!(parsed[0].list_sigil_end(), Some(2));
1179    }
1180
1181    #[test]
1182    fn buffer_parse_empty_lines_preserved() {
1183        let lines = vec![
1184            "# Title".to_string(),
1185            String::new(),
1186            "paragraph".to_string(),
1187        ];
1188        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1189        assert_eq!(parsed.len(), 3);
1190        assert_eq!(parsed[1].elements.len(), 0);
1191        assert_eq!(parsed[1].content_vis.len(), 0);
1192    }
1193
1194    #[test]
1195    fn buffer_parse_ordered_nested_list() {
1196        let lines = vec!["1. first".to_string(), "    1. nested".to_string()];
1197        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1198        assert_eq!(parsed[0].list_sigil_end(), Some(3));
1199        assert_eq!(parsed[1].list_sigil_end(), Some(7));
1200    }
1201
1202    #[test]
1203    fn buffer_parse_setext_h1_spans_two_rows() {
1204        // Setext H1: the `=====` line is part of the heading span.
1205        // Under the old per-line parser, row 1 rendered as plain text; under the
1206        // whole-buffer parser, pulldown emits one HeadingH1 covering both rows and
1207        // row 1 has no Text events, so the underline renders in the sigil color.
1208        // Pin this behavior — a regression would silently un-style setext headings.
1209        let lines = vec!["My Heading".to_string(), "==========".to_string()];
1210        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1211        assert!(
1212            parsed[0]
1213                .elements
1214                .iter()
1215                .any(|e| e.kind == ElementKind::HeadingH1),
1216            "setext underline must tag row 0 as HeadingH1"
1217        );
1218        assert!(
1219            parsed[1]
1220                .elements
1221                .iter()
1222                .any(|e| e.kind == ElementKind::HeadingH1),
1223            "setext underline must tag row 1 as HeadingH1"
1224        );
1225        // Row 1 has no Text events — content_vis is all false.
1226        assert!(
1227            parsed[1].content_vis.iter().all(|v| !v),
1228            "setext underline row has no content"
1229        );
1230    }
1231
1232    #[test]
1233    fn buffer_parse_multiline_blockquote() {
1234        // Two blockquote lines in a row — pulldown folds them into one blockquote.
1235        // Both rows must carry a Blockquote element so rendering is consistent.
1236        let lines = vec!["> first line".to_string(), "> second line".to_string()];
1237        let parsed = ParsedBuffer::parse_lines(&lines).lines;
1238        assert!(
1239            parsed[0]
1240                .elements
1241                .iter()
1242                .any(|e| e.kind == ElementKind::Blockquote),
1243            "row 0 must tag as Blockquote"
1244        );
1245        assert!(
1246            parsed[1]
1247                .elements
1248                .iter()
1249                .any(|e| e.kind == ElementKind::Blockquote),
1250            "row 1 must tag as Blockquote"
1251        );
1252    }
1253
1254    #[test]
1255    fn parse_line_emits_label_for_hashtag() {
1256        let line = "see #rust later";
1257        let parsed = ParsedLine::parse(line);
1258        let label = parsed
1259            .elements
1260            .iter()
1261            .find(|e| matches!(e.kind, ElementKind::Label));
1262        assert!(
1263            label.is_some(),
1264            "expected Label element: {:?}",
1265            parsed.elements
1266        );
1267        let l = label.unwrap();
1268        let span: String = line
1269            .chars()
1270            .skip(l.start_char)
1271            .take(l.end_char - l.start_char)
1272            .collect();
1273        assert_eq!(span, "#rust");
1274    }
1275
1276    #[test]
1277    fn parse_line_skips_label_inside_inline_code() {
1278        let parsed = ParsedLine::parse("use `#foo` here");
1279        let has_label = parsed
1280            .elements
1281            .iter()
1282            .any(|e| matches!(e.kind, ElementKind::Label));
1283        assert!(!has_label, "should not emit Label inside inline code");
1284    }
1285
1286    // ── New label-parity tests (F2, F3, F4) ──────────────────────────────────
1287
1288    #[test]
1289    fn parse_line_skips_label_inside_markdown_link() {
1290        let parsed = ParsedLine::parse("[see docs](#section) and #real");
1291        let labels: Vec<_> = parsed
1292            .elements
1293            .iter()
1294            .filter(|e| matches!(e.kind, ElementKind::Label))
1295            .collect();
1296        assert_eq!(
1297            labels.len(),
1298            1,
1299            "only #real should be a label, not #section in the link"
1300        );
1301        let l = labels[0];
1302        let span: String = "[see docs](#section) and #real"
1303            .chars()
1304            .skip(l.start_char)
1305            .take(l.end_char - l.start_char)
1306            .collect();
1307        assert_eq!(span, "#real");
1308    }
1309
1310    #[test]
1311    fn parse_line_skips_label_inside_link_display_text() {
1312        let parsed = ParsedLine::parse("[#todo](notes/project.md)");
1313        let has_label = parsed
1314            .elements
1315            .iter()
1316            .any(|e| matches!(e.kind, ElementKind::Label));
1317        assert!(
1318            !has_label,
1319            "hashtag inside link display text should not become Label"
1320        );
1321    }
1322
1323    #[test]
1324    fn parse_line_skips_label_after_label_char() {
1325        let parsed = ParsedLine::parse("foo#bar baz");
1326        let has_label = parsed
1327            .elements
1328            .iter()
1329            .any(|e| matches!(e.kind, ElementKind::Label));
1330        assert!(
1331            !has_label,
1332            "word#tag should not emit Label without word boundary"
1333        );
1334    }
1335
1336    #[test]
1337    fn parse_line_skips_label_for_double_hash() {
1338        // `##draft` is Markdown header territory, not a label — pin the
1339        // highlighter to the same rule the indexer enforces so a future
1340        // core relaxation cannot silently re-color this span.
1341        let parsed = ParsedLine::parse("##draft");
1342        let has_label = parsed
1343            .elements
1344            .iter()
1345            .any(|e| matches!(e.kind, ElementKind::Label));
1346        assert!(!has_label, "##draft should not emit Label");
1347    }
1348
1349    #[test]
1350    fn parse_line_skips_label_for_adjacent_hash_run() {
1351        // `#tag#more` — adjacent `#` invalidates both halves at the index
1352        // level; the highlighter must agree to avoid suggesting tags that
1353        // will never appear in the labels table.
1354        let parsed = ParsedLine::parse("#tag#more");
1355        let labels: Vec<_> = parsed
1356            .elements
1357            .iter()
1358            .filter(|e| matches!(e.kind, ElementKind::Label))
1359            .collect();
1360        assert!(
1361            labels.is_empty(),
1362            "#tag#more should not emit Label, got {:?}",
1363            labels
1364        );
1365    }
1366
1367    #[test]
1368    fn parse_buffer_skips_label_inside_fenced_block() {
1369        let buffer = vec![
1370            "before".to_string(),
1371            "```".to_string(),
1372            "#inside".to_string(),
1373            "```".to_string(),
1374            "after #outside".to_string(),
1375        ];
1376        let lines = ParsedBuffer::parse_lines(&buffer).lines;
1377        let inside_labels: Vec<_> = lines[2]
1378            .elements
1379            .iter()
1380            .filter(|e| matches!(e.kind, ElementKind::Label))
1381            .collect();
1382        assert!(
1383            inside_labels.is_empty(),
1384            "no Label emitted for hashtags in fenced blocks"
1385        );
1386
1387        let outside_labels: Vec<_> = lines[4]
1388            .elements
1389            .iter()
1390            .filter(|e| matches!(e.kind, ElementKind::Label))
1391            .collect();
1392        assert_eq!(outside_labels.len(), 1, "#outside still extracted");
1393    }
1394
1395    #[test]
1396    fn parse_range_full_equals_parse() {
1397        let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
1398        let full = ParsedBuffer::parse_lines(&lines);
1399        let range_full = ParsedBuffer::parse_range_lines(&lines, 0..lines.len());
1400        assert_eq!(full.lines.len(), range_full.lines.len());
1401        assert_eq!(full.kinds, range_full.kinds);
1402        for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
1403            assert_eq!(a.content_vis, b.content_vis);
1404            assert_eq!(a.elements.len(), b.elements.len());
1405        }
1406    }
1407
1408    #[test]
1409    fn parse_range_paragraph_only_slice() {
1410        let lines: Vec<String> = vec![
1411            "intro paragraph".into(),
1412            "".into(),
1413            "middle line".into(),
1414            "".into(),
1415            "outro".into(),
1416        ];
1417        let slice = ParsedBuffer::parse_range_lines(&lines, 2..3);
1418        assert_eq!(slice.lines.len(), 1);
1419        assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
1420    }
1421
1422    #[test]
1423    fn splice_replaces_range() {
1424        let mut pb = ParsedBuffer::parse_lines(&["alpha".into(), "beta".into(), "gamma".into()]);
1425        let replacement = ParsedBuffer::parse_lines(&["BETA-NEW".into()]);
1426        let replacement_kind = replacement.kinds[0];
1427        pb.splice(1..2, replacement);
1428        assert_eq!(pb.lines.len(), 3);
1429        assert_eq!(pb.kinds.len(), 3);
1430        assert_eq!(
1431            pb.kinds[1], replacement_kind,
1432            "replacement landed at the wrong index"
1433        );
1434    }
1435
1436    #[cfg(debug_assertions)]
1437    #[test]
1438    #[should_panic(expected = "splice")]
1439    fn splice_panics_on_length_mismatch_in_debug() {
1440        let mut pb = ParsedBuffer::parse_lines(&["a".into(), "b".into()]);
1441        let too_short = ParsedBuffer::parse_lines(&["X".into()]);
1442        pb.splice(0..2, too_short);
1443    }
1444
1445    // ── V2 lazy_depth tracking ───────────────────────────────────────────────
1446
1447    /// CORRECTED FROM SPEC: tasks.md 2.1 asserted `[1, 1, 1, 0]`,
1448    /// claiming blockquote lazy-extends across blanks. This is
1449    /// incorrect per CommonMark §5.1 — a blank line ENDS a
1450    /// blockquote (see Example 209). Pulldown closes the blockquote
1451    /// at the first blank, so lazy_depth drops there. The §5.1 lazy
1452    /// "paragraph continuation" cited in the spec is about non-`>`
1453    /// lines continuing an OPEN paragraph (still on the same line
1454    /// run), not extending the blockquote across blanks.
1455    #[test]
1456    fn lazy_depth_blockquote_closes_at_first_blank() {
1457        let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
1458        let pb = ParsedBuffer::parse_lines(&lines);
1459        assert_eq!(
1460            pb.lazy_depth,
1461            vec![1, 0, 0, 0],
1462            "blockquote closes at first blank per CommonMark §5.1; got {:?}",
1463            pb.lazy_depth,
1464        );
1465    }
1466
1467    /// IndentedCode lazy-extends across a blank row joining two
1468    /// indented chunks (CommonMark §4.4). All three rows must
1469    /// report lazy_depth ≥ 1 — including the last content row, so
1470    /// the v2 structural guard catches edits anywhere inside the
1471    /// block.
1472    #[test]
1473    fn lazy_depth_indented_code_across_blanks() {
1474        let lines: Vec<String> = vec!["    code".into(), "".into(), "    more".into()];
1475        let pb = ParsedBuffer::parse_lines(&lines);
1476        assert_eq!(
1477            pb.lazy_depth,
1478            vec![1, 1, 1],
1479            "indented code multi-chunk should keep lazy_depth > 0 across the blank \
1480             AND through the last content row; got {:?}",
1481            pb.lazy_depth,
1482        );
1483    }
1484
1485    /// Fenced code blocks are NOT lazy-continuable — their closing
1486    /// fence is a hard terminator. lazy_depth must remain 0 on
1487    /// every row.
1488    #[test]
1489    fn lazy_depth_fenced_code_does_not_count() {
1490        let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
1491        let pb = ParsedBuffer::parse_lines(&lines);
1492        assert_eq!(
1493            pb.lazy_depth,
1494            vec![0, 0, 0, 0],
1495            "fenced code is not lazy-continuable; got {:?}",
1496            pb.lazy_depth,
1497        );
1498    }
1499
1500    /// Regression: BlockQuote followed by a trailing blank row must
1501    /// drop `lazy_depth` AT the blank row, not past it. The buggy
1502    /// past-EOF heuristic in `byte_to_row_col_unclamped` mis-fired
1503    /// for End events landing on the START of a trailing empty row
1504    /// (binary_search returned `Ok(r)` with `r < lines.len()` and a
1505    /// 0-length row), shunting the decrement into the past-array
1506    /// sentinel slot and leaving `lazy_depth[r]` elevated. That in
1507    /// turn suppressed the legitimate reset boundary at row r and
1508    /// forced full rebuilds on every edit adjacent to a trailing
1509    /// blank.
1510    #[test]
1511    fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
1512        let lines: Vec<String> = vec!["> a".into(), "".into()];
1513        let pb = ParsedBuffer::parse_lines(&lines);
1514        assert_eq!(
1515            pb.lazy_depth,
1516            vec![1, 0],
1517            "blockquote must close at the trailing blank; got {:?}",
1518            pb.lazy_depth,
1519        );
1520        assert!(
1521            pb.reset_boundaries.contains(&1),
1522            "the trailing blank at row 1 must be a reset boundary; got {:?}",
1523            pb.reset_boundaries,
1524        );
1525    }
1526
1527    /// Boundary detection must skip rows inside a lazy-continuable
1528    /// block. Using the IndentedCode multi-chunk fixture (the
1529    /// canonical §4.4 case) every row has lazy_depth > 0, so no
1530    /// interior boundary can land. Only the sentinels remain.
1531    ///
1532    /// CORRECTED FROM SPEC: tasks.md 2.4 used the blockquote
1533    /// fixture from 2.1, which does NOT produce interior
1534    /// lazy_depth > 0 rows (blanks end the blockquote). The
1535    /// IndentedCode multi-chunk fixture is the correct one for
1536    /// this invariant.
1537    #[test]
1538    fn boundaries_skip_rows_inside_lazy_block() {
1539        let lines: Vec<String> = vec!["    code".into(), "".into(), "    more".into()];
1540        let pb = ParsedBuffer::parse_lines(&lines);
1541        assert_eq!(
1542            pb.reset_boundaries,
1543            vec![0, lines.len()],
1544            "no boundary should land on a blank row inside the open indented-code block; \
1545             got {:?}",
1546            pb.reset_boundaries,
1547        );
1548    }
1549}