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