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