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