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.gray.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.aqua.to_ratatui())
411            .bg(theme.bg_soft.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        // Spec §5.1: H1/H2 bright + bold, H3 yellow + bold.
419        Some(ElementKind::HeadingH1) | Some(ElementKind::HeadingH2) => {
420            if is_sigil_region {
421                Style::default().fg(theme.gray.to_ratatui())
422            } else {
423                Style::default()
424                    .fg(theme.fg_bright.to_ratatui())
425                    .add_modifier(Modifier::BOLD)
426            }
427        }
428        Some(ElementKind::HeadingH3) => {
429            if is_sigil_region {
430                Style::default().fg(theme.gray.to_ratatui())
431            } else {
432                Style::default()
433                    .fg(theme.yellow.to_ratatui())
434                    .add_modifier(Modifier::BOLD)
435            }
436        }
437        Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
438        // Spec §5.1: wikilink targets are blue + underlined.
439        Some(ElementKind::WikiLink) => Style::default()
440            .fg(theme.blue.to_ratatui())
441            .add_modifier(Modifier::UNDERLINED),
442        Some(ElementKind::Label) => Style::default()
443            .fg(theme.color_tag.to_ratatui())
444            .add_modifier(Modifier::BOLD),
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::super::parse_incremental::LineConstructKind;
451    use super::*;
452    use ratatui::style::Modifier;
453    fn t() -> Theme {
454        Theme::default()
455    }
456    fn text(spans: &[Span]) -> String {
457        spans.iter().map(|s| s.content.as_ref()).collect()
458    }
459
460    #[test]
461    fn blockquote_lazy_continuation_carries_depth() {
462        // A line with no leading `>` that lazily continues a blockquote
463        // (CommonMark §5.1) is part of the quote: pulldown spans the
464        // Blockquote element across it, so it must report the quote's depth
465        // and get the bar gutter — not just the quoted text color.
466        let buf = ParsedBuffer::parse(&["> first".to_string(), "second".to_string()]);
467        assert_eq!(buf.lines[0].blockquote_depth(), Some(1));
468        assert_eq!(
469            buf.lines[1].blockquote_depth(),
470            Some(1),
471            "lazy continuation line must carry the blockquote depth"
472        );
473
474        // Nested lazy continuation keeps the full depth.
475        let nested = ParsedBuffer::parse(&[">> a".to_string(), "b".to_string()]);
476        assert_eq!(nested.lines[0].blockquote_depth(), Some(2));
477        assert_eq!(nested.lines[1].blockquote_depth(), Some(2));
478
479        // A blank line ends the quote: the following line is NOT a continuation.
480        let ended =
481            ParsedBuffer::parse(&["> first".to_string(), String::new(), "plain".to_string()]);
482        assert_eq!(ended.lines[0].blockquote_depth(), Some(1));
483        assert_eq!(ended.lines[2].blockquote_depth(), None);
484    }
485
486    #[test]
487    fn indented_code_excludes_trailing_blank_keeps_interior() {
488        use super::super::parse_incremental::LineConstructKind::{Blank, IndentedCode, Plain};
489        let kinds = |lines: &[&str]| {
490            let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
491            ParsedBuffer::parse(&owned).kinds
492        };
493        // Trailing blank after indented code is NOT part of the block.
494        assert_eq!(
495            kinds(&["    code", "", "outro"]),
496            vec![IndentedCode, Blank, Plain]
497        );
498        // Interior blank (between indented lines) stays part of the block.
499        assert_eq!(
500            kinds(&["    a", "", "    b"]),
501            vec![IndentedCode, IndentedCode, IndentedCode]
502        );
503        // Multiple trailing blanks all revert to Blank.
504        assert_eq!(
505            kinds(&["    a", "", "", "outro"]),
506            vec![IndentedCode, Blank, Blank, Plain]
507        );
508        // A less-indented content line (no blank before it) ends the block —
509        // it is NOT swallowed into the code block.
510        assert_eq!(
511            kinds(&["    Line 1", "    Line 2", "Line 3"]),
512            vec![IndentedCode, IndentedCode, Plain]
513        );
514        // Interior blanks (any number) between indented chunks stay in-block.
515        assert_eq!(
516            kinds(&["    line 1", "    line 2", "", "", "    line 3"]),
517            vec![
518                IndentedCode,
519                IndentedCode,
520                IndentedCode,
521                IndentedCode,
522                IndentedCode
523            ]
524        );
525    }
526
527    #[test]
528    fn gutter_width_matches_rendered() {
529        // Locks the single-source contract: blockquote_gutter_width must equal
530        // the display width of the actually-rendered blockquote_gutter string.
531        for d in 1u8..=4 {
532            assert_eq!(
533                blockquote_gutter_width(d),
534                string_display_width(&blockquote_gutter(d)),
535                "gutter width/string disagree at depth {d}"
536            );
537        }
538    }
539
540    #[test]
541    fn blockquote_depth_and_sigil_end() {
542        let p = ParsedLine::parse("> hello");
543        assert_eq!(p.blockquote_depth(), Some(1));
544        // sigil region is "> " (2 chars); content starts at index 2.
545        assert_eq!(p.blockquote_sigil_end(), Some(2));
546
547        let p2 = ParsedLine::parse(">> deep");
548        assert_eq!(p2.blockquote_depth(), Some(2));
549
550        let plain = ParsedLine::parse("not a quote");
551        assert_eq!(plain.blockquote_depth(), None);
552        assert_eq!(plain.blockquote_sigil_end(), None);
553    }
554    #[test]
555    fn parse_bold_range() {
556        let e = MarkdownSpanner::parse_elements("**bold**");
557        let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
558        assert_eq!((b.start_char, b.end_char), (0, 8));
559    }
560    #[test]
561    fn parse_italic() {
562        assert!(
563            MarkdownSpanner::parse_elements("*hi*")
564                .iter()
565                .any(|e| e.kind == ElementKind::Italic)
566        );
567    }
568    #[test]
569    fn parse_strikethrough() {
570        let e = MarkdownSpanner::parse_elements("~~gone~~");
571        let s = e
572            .iter()
573            .find(|x| x.kind == ElementKind::Strikethrough)
574            .unwrap();
575        assert_eq!((s.start_char, s.end_char), (0, 8));
576    }
577    #[test]
578    fn strikethrough_renders_with_crossed_out_modifier() {
579        let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
580        assert_eq!(text(&s), "gone");
581        assert!(
582            s.iter()
583                .any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
584        );
585    }
586    #[test]
587    fn parse_inline_code() {
588        assert!(
589            MarkdownSpanner::parse_elements("`x`")
590                .iter()
591                .any(|e| e.kind == ElementKind::InlineCode)
592        );
593    }
594    #[test]
595    fn parse_link() {
596        assert!(
597            MarkdownSpanner::parse_elements("[t](u)")
598                .iter()
599                .any(|e| e.kind == ElementKind::Link)
600        );
601    }
602
603    #[test]
604    fn parse_image_emits_image_element_and_placeholder() {
605        let line = "see ![alt](../assets/img.png) here";
606        let parsed = ParsedLine::parse(line);
607        let img = parsed
608            .elements
609            .iter()
610            .find(|e| e.kind == ElementKind::Image)
611            .expect("image element");
612        assert_eq!(line.chars().nth(img.start_char), Some('!'));
613        assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
614        let ph = parsed
615            .image_placeholders
616            .iter()
617            .find(|p| p.start_char == img.start_char)
618            .expect("placeholder for image");
619        assert_eq!(ph.placeholder, "[img.png]");
620        for pos in img.start_char..img.end_char {
621            assert!(
622                !parsed.content_vis[pos],
623                "char {pos} should be hidden inside image span"
624            );
625        }
626    }
627
628    #[test]
629    fn render_image_substitutes_placeholder_text() {
630        let line = "before ![alt](pic.gif) after";
631        let parsed = ParsedLine::parse(line);
632        let spans =
633            MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
634        let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
635        assert!(
636            rendered.contains("[pic.gif]"),
637            "rendered text {rendered:?} should include placeholder"
638        );
639        assert!(
640            !rendered.contains("![alt]"),
641            "raw image syntax should not appear in rendered output: {rendered:?}"
642        );
643    }
644
645    #[test]
646    fn render_image_with_empty_alt_uses_filename() {
647        let line = "![](image.png)";
648        let parsed = ParsedLine::parse(line);
649        let spans =
650            MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
651        let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
652        assert_eq!(rendered, "[image.png]");
653    }
654
655    #[test]
656    fn rendered_cursor_col_accounts_for_placeholder_width() {
657        // "![](x.png)" → placeholder "[x.png]" (7 chars) replaces 10 source chars.
658        let line = "a ![](x.png) b";
659        let parsed = ParsedLine::parse(line);
660        let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
661            line,
662            &parsed,
663            0,
664            "a ![](x.png) b".chars().count(), // cursor at end
665            true,
666            false,
667        );
668        // "a " (2) + "[x.png]" (7) + " b" (2) = 11.
669        assert_eq!(after_placeholder, 11);
670    }
671    #[test]
672    fn parse_h1() {
673        assert!(
674            MarkdownSpanner::parse_elements("# T")
675                .iter()
676                .any(|e| e.kind == ElementKind::HeadingH1)
677        );
678    }
679    #[test]
680    fn parse_h2() {
681        assert!(
682            MarkdownSpanner::parse_elements("## T")
683                .iter()
684                .any(|e| e.kind == ElementKind::HeadingH2)
685        );
686    }
687    #[test]
688    fn parse_h3() {
689        assert!(
690            MarkdownSpanner::parse_elements("### T")
691                .iter()
692                .any(|e| e.kind == ElementKind::HeadingH3)
693        );
694    }
695    #[test]
696    fn force_raw_no_styling() {
697        let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
698        assert_eq!(text(&s), "**x**");
699        assert!(
700            !s.iter()
701                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
702        );
703    }
704    #[test]
705    fn plain_text_passthrough() {
706        let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
707        assert_eq!(text(&s), "hi");
708    }
709    #[test]
710    fn bold_without_cursor_hides_markers() {
711        let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
712        assert_eq!(text(&s), "bold");
713        assert!(
714            s.iter()
715                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
716        );
717    }
718    #[test]
719    fn bold_cursor_inside_shows_raw() {
720        let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
721        assert_eq!(text(&s), "**bold**");
722    }
723    #[test]
724    fn bold_cursor_outside_stays_rendered() {
725        let line = "hello **bold** world";
726        let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
727        assert!(!text(&s).contains("**"));
728    }
729    #[test]
730    fn italic_cursor_inside_shows_raw() {
731        let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
732        assert_eq!(text(&s), "*hi*");
733    }
734    #[test]
735    fn inline_code_hides_backticks() {
736        let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
737        assert_eq!(text(&s), "x");
738    }
739    #[test]
740    fn h1_first_line_contains_hash() {
741        let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
742        assert!(text(&s).contains('#'));
743        assert!(text(&s).contains('T'));
744    }
745    #[test]
746    fn continuation_line_no_hash() {
747        let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
748        assert!(!text(&s).contains('#'));
749    }
750    #[test]
751    fn unordered_list_shows_marker() {
752        let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
753        assert!(
754            text(&s).starts_with("- "),
755            "expected '- item', got '{}'",
756            text(&s)
757        );
758        assert!(text(&s).contains("item"));
759    }
760    #[test]
761    fn ordered_list_shows_marker() {
762        let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
763        assert!(
764            text(&s).starts_with("1. "),
765            "expected '1. item', got '{}'",
766            text(&s)
767        );
768    }
769    #[test]
770    fn nested_list_4space_link_rendered() {
771        // 4-space indent + list marker + markdown link.
772        let line = "    - [my link](url)";
773        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
774        // Link styling must appear (UNDERLINED modifier) and the raw "](url)" sigils
775        // must be hidden.
776        assert!(
777            s.iter()
778                .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
779            "link text should be underlined on a 4-space-indented nested list item"
780        );
781        let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
782        assert!(
783            rendered.contains("my link"),
784            "link display text should be visible; got {:?}",
785            rendered
786        );
787        assert!(
788            !rendered.contains("](url)"),
789            "link URL sigil should be hidden; got {:?}",
790            rendered
791        );
792    }
793
794    #[test]
795    fn nested_list_tab_bold_rendered() {
796        let line = "\t- **bold nested**";
797        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
798        assert!(
799            s.iter()
800                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
801            "bold text should be styled on a tab-indented nested list item"
802        );
803        let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
804        assert!(
805            !rendered.contains("**"),
806            "bold markers should be hidden; got {:?}",
807            rendered
808        );
809    }
810
811    #[test]
812    fn nested_list_4space_wikilink_rendered() {
813        let line = "    - [[Target Note]]";
814        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
815        let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
816        assert!(
817            !rendered.contains("[["),
818            "wikilink brackets should be hidden; got {:?}",
819            rendered
820        );
821        assert!(
822            rendered.contains("Target Note"),
823            "wikilink target text should render; got {:?}",
824            rendered
825        );
826    }
827
828    #[test]
829    fn nested_list_2space_still_renders_link() {
830        // Existing 2-space case — must not regress.
831        let line = "  - [link](url)";
832        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
833        assert!(
834            s.iter()
835                .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
836        );
837    }
838
839    #[test]
840    fn empty_heading_shows_hash_sigil() {
841        let line = "# ";
842        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
843        assert!(
844            text(&s).contains('#'),
845            "hash sigil should render in empty heading"
846        );
847        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
848        assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
849    }
850    #[test]
851    fn empty_heading_hash_only_shows() {
852        let line = "#";
853        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
854        assert!(text(&s).contains('#'));
855        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
856        assert_eq!(col, 1);
857    }
858    #[test]
859    fn heading_trailing_spaces_are_rendered() {
860        let line = "# Hello   ";
861        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
862        assert_eq!(
863            text(&s),
864            "# Hello   ",
865            "trailing spaces in heading should render"
866        );
867    }
868    #[test]
869    fn heading_trailing_spaces_cursor_col_correct() {
870        let line = "# Hello   ";
871        // cursor at logical pos 9 (last trailing space): positions 0..9 all emit → rendered col 9
872        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
873        assert_eq!(
874            col, 9,
875            "cursor in trailing space of heading should map to rendered col 9"
876        );
877    }
878    #[test]
879    fn trailing_spaces_are_rendered() {
880        let line = "hello   ";
881        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
882        assert_eq!(text(&s), "hello   ");
883    }
884    #[test]
885    fn trailing_spaces_cursor_col_correct() {
886        let line = "hello   ";
887        let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
888        assert_eq!(col, 7);
889    }
890    #[test]
891    fn list_marker_on_continuation_line_hidden() {
892        let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
893        assert!(!text(&s).starts_with("- "));
894    }
895    #[test]
896    fn parsed_line_heading_sigil_end_empty_heading() {
897        // "#" alone: no content chars, sigil_end should equal e.end_char (1)
898        let p = ParsedLine::parse("#");
899        assert_eq!(p.heading_sigil_end(), Some(1));
900    }
901    #[test]
902    fn parsed_line_heading_sigil_end_with_content() {
903        // "# T": sigil is "# " (2 chars), first content at pos 2
904        let p = ParsedLine::parse("# T");
905        assert_eq!(p.heading_sigil_end(), Some(2));
906    }
907    #[test]
908    fn parsed_line_reuse_matches_individual() {
909        let line = "**hello** world";
910        let parsed = ParsedLine::parse(line);
911        let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
912        let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
913        assert_eq!(
914            s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
915            s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
916        );
917    }
918
919    // ── WikiLink tests ────────────────────────────────────────────────────────
920
921    #[test]
922    fn parse_wikilink() {
923        let e = MarkdownSpanner::parse_elements("[[My Note]]");
924        let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
925        assert_eq!((wl.start_char, wl.end_char), (0, 11));
926    }
927
928    #[test]
929    fn wikilink_without_cursor_hides_brackets() {
930        let line = "[[My Note]]";
931        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
932        assert_eq!(text(&s), "My Note");
933        assert!(
934            s.iter()
935                .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
936        );
937    }
938
939    #[test]
940    fn wikilink_cursor_inside_shows_brackets() {
941        let line = "[[My Note]]";
942        // cursor at pos 4 (inside "My Note")
943        let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
944        assert_eq!(text(&s), "[[My Note]]");
945    }
946
947    #[test]
948    fn wikilink_cursor_outside_hides_brackets() {
949        let line = "hello [[My Note]] world";
950        let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
951        assert!(!text(&s).contains("[["));
952        assert!(!text(&s).contains("]]"));
953    }
954
955    #[test]
956    fn wikilink_in_heading_rendered() {
957        let line = "# See [[Topic]]";
958        let e = MarkdownSpanner::parse_elements(line);
959        assert!(
960            e.iter().any(|x| x.kind == ElementKind::WikiLink),
961            "wikilink inside heading should produce a WikiLink element"
962        );
963        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
964        assert_eq!(text(&s), "# See Topic", "wikilink brackets hidden, # kept");
965    }
966
967    #[test]
968    fn heading_with_link_does_not_leak_bracket() {
969        let line = "# [text](http://x)";
970        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
971        // `# ` sigil stays visible (heading marker); link sigils incl. the
972        // leading `[` are hidden, leaving just the display text.
973        assert_eq!(text(&s), "# text");
974    }
975
976    #[test]
977    fn heading_with_bold_does_not_leak_asterisk() {
978        let line = "# **bold**";
979        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
980        assert_eq!(
981            text(&s),
982            "# bold",
983            "leading * must not leak into heading sigil"
984        );
985    }
986
987    #[test]
988    fn bold_wikilink_is_bold() {
989        let line = "**[[Topic]]**";
990        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
991        assert_eq!(text(&s), "Topic");
992        assert!(
993            s.iter()
994                .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)
995                    && sp.content.contains("Topic")),
996            "wikilink wrapped in ** must render bold"
997        );
998    }
999
1000    #[test]
1001    fn italic_link_is_italic() {
1002        let line = "*[text](http://x)*";
1003        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1004        assert_eq!(text(&s), "text");
1005        assert!(
1006            s.iter()
1007                .any(|sp| sp.style.add_modifier.contains(Modifier::ITALIC)
1008                    && sp.content.contains("text")),
1009            "link wrapped in * must render italic"
1010        );
1011    }
1012
1013    #[test]
1014    fn bold_italic_wikilink_is_bold_and_italic() {
1015        let line = "***[[Topic]]***";
1016        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1017        assert_eq!(text(&s), "Topic");
1018        assert!(
1019            s.iter().any(|sp| {
1020                sp.content.contains("Topic")
1021                    && sp.style.add_modifier.contains(Modifier::BOLD)
1022                    && sp.style.add_modifier.contains(Modifier::ITALIC)
1023            }),
1024            "wikilink in *** *** must render both bold and italic"
1025        );
1026    }
1027
1028    #[test]
1029    fn bold_italic_plain_text() {
1030        let line = "***text***";
1031        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1032        assert_eq!(text(&s), "text");
1033        assert!(
1034            s.iter().any(|sp| {
1035                sp.content.contains("text")
1036                    && sp.style.add_modifier.contains(Modifier::BOLD)
1037                    && sp.style.add_modifier.contains(Modifier::ITALIC)
1038            }),
1039            "*** *** must render both bold and italic"
1040        );
1041    }
1042
1043    #[test]
1044    fn wikilink_mid_sentence() {
1045        let line = "See [[Topic]] for details";
1046        let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1047        assert_eq!(text(&s), "See Topic for details");
1048    }
1049
1050    #[test]
1051    fn wikilink_cursor_col_accounts_for_brackets() {
1052        // "[[Hi]]" — cursor at pos 2 ('H') is inside the element, so it expands.
1053        // Rendered col counts pos 0 ('['), pos 1 ('[') as visible (expanded sigils) → col = 2.
1054        let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
1055        assert_eq!(col, 2);
1056
1057        // Cursor outside the wikilink (pos 0 on a plain-text line before it):
1058        // "See [[Hi]] x" with cursor at pos 0 — wikilink not expanded, brackets hidden.
1059        // pos 0 ('S') is plain text, rendered col = 0.
1060        let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
1061        assert_eq!(col2, 0);
1062    }
1063
1064    #[test]
1065    fn buffer_parse_nested_list_under_parent() {
1066        // Canonical nested-list pattern: parent at col 0, child indented 4.
1067        let lines = vec![
1068            "- parent".to_string(),
1069            "    - [child link](url)".to_string(),
1070        ];
1071        let parsed = ParsedBuffer::parse(&lines).lines;
1072        assert_eq!(parsed.len(), 2);
1073
1074        // Parent line: list sigil at col 2.
1075        assert_eq!(parsed[0].list_sigil_end(), Some(2));
1076
1077        // Child line: pulldown-cmark reports the item marker at col 4.
1078        assert_eq!(
1079            parsed[1].list_sigil_end(),
1080            Some(6),
1081            "child's sigil_end should be after '    - ' (6 chars)"
1082        );
1083
1084        // Child line has a Link element.
1085        assert!(
1086            parsed[1]
1087                .elements
1088                .iter()
1089                .any(|e| e.kind == ElementKind::Link),
1090            "nested list item should contain a Link element"
1091        );
1092    }
1093
1094    #[test]
1095    fn buffer_parse_standalone_2space_list_still_works() {
1096        // Regression: 2-space indent works on its own too.
1097        let lines = vec!["  - [link](url)".to_string()];
1098        let parsed = ParsedBuffer::parse(&lines).lines;
1099        assert!(
1100            parsed[0]
1101                .elements
1102                .iter()
1103                .any(|e| e.kind == ElementKind::Link)
1104        );
1105        assert_eq!(parsed[0].list_sigil_end(), Some(4));
1106    }
1107
1108    #[test]
1109    fn buffer_parse_top_level_unchanged() {
1110        // Ensure nothing about top-level rendering changed.
1111        let lines = vec!["- [link](url)".to_string()];
1112        let parsed = ParsedBuffer::parse(&lines).lines;
1113        assert!(
1114            parsed[0]
1115                .elements
1116                .iter()
1117                .any(|e| e.kind == ElementKind::Link)
1118        );
1119        assert_eq!(parsed[0].list_sigil_end(), Some(2));
1120    }
1121
1122    #[test]
1123    fn buffer_parse_empty_lines_preserved() {
1124        let lines = vec![
1125            "# Title".to_string(),
1126            String::new(),
1127            "paragraph".to_string(),
1128        ];
1129        let parsed = ParsedBuffer::parse(&lines).lines;
1130        assert_eq!(parsed.len(), 3);
1131        assert_eq!(parsed[1].elements.len(), 0);
1132        assert_eq!(parsed[1].content_vis.len(), 0);
1133    }
1134
1135    #[test]
1136    fn buffer_parse_ordered_nested_list() {
1137        let lines = vec!["1. first".to_string(), "    1. nested".to_string()];
1138        let parsed = ParsedBuffer::parse(&lines).lines;
1139        assert_eq!(parsed[0].list_sigil_end(), Some(3));
1140        assert_eq!(parsed[1].list_sigil_end(), Some(7));
1141    }
1142
1143    #[test]
1144    fn buffer_parse_setext_h1_spans_two_rows() {
1145        // Setext H1: the `=====` line is part of the heading span.
1146        // Under the old per-line parser, row 1 rendered as plain text; under the
1147        // whole-buffer parser, pulldown emits one HeadingH1 covering both rows and
1148        // row 1 has no Text events, so the underline renders in the sigil color.
1149        // Pin this behavior — a regression would silently un-style setext headings.
1150        let lines = vec!["My Heading".to_string(), "==========".to_string()];
1151        let parsed = ParsedBuffer::parse(&lines).lines;
1152        assert!(
1153            parsed[0]
1154                .elements
1155                .iter()
1156                .any(|e| e.kind == ElementKind::HeadingH1),
1157            "setext underline must tag row 0 as HeadingH1"
1158        );
1159        assert!(
1160            parsed[1]
1161                .elements
1162                .iter()
1163                .any(|e| e.kind == ElementKind::HeadingH1),
1164            "setext underline must tag row 1 as HeadingH1"
1165        );
1166        // Row 1 has no Text events — content_vis is all false.
1167        assert!(
1168            parsed[1].content_vis.iter().all(|v| !v),
1169            "setext underline row has no content"
1170        );
1171    }
1172
1173    #[test]
1174    fn buffer_parse_multiline_blockquote() {
1175        // Two blockquote lines in a row — pulldown folds them into one blockquote.
1176        // Both rows must carry a Blockquote element so rendering is consistent.
1177        let lines = vec!["> first line".to_string(), "> second line".to_string()];
1178        let parsed = ParsedBuffer::parse(&lines).lines;
1179        assert!(
1180            parsed[0]
1181                .elements
1182                .iter()
1183                .any(|e| e.kind == ElementKind::Blockquote),
1184            "row 0 must tag as Blockquote"
1185        );
1186        assert!(
1187            parsed[1]
1188                .elements
1189                .iter()
1190                .any(|e| e.kind == ElementKind::Blockquote),
1191            "row 1 must tag as Blockquote"
1192        );
1193    }
1194
1195    #[test]
1196    fn parse_line_emits_label_for_hashtag() {
1197        let line = "see #rust later";
1198        let parsed = ParsedLine::parse(line);
1199        let label = parsed
1200            .elements
1201            .iter()
1202            .find(|e| matches!(e.kind, ElementKind::Label));
1203        assert!(
1204            label.is_some(),
1205            "expected Label element: {:?}",
1206            parsed.elements
1207        );
1208        let l = label.unwrap();
1209        let span: String = line
1210            .chars()
1211            .skip(l.start_char)
1212            .take(l.end_char - l.start_char)
1213            .collect();
1214        assert_eq!(span, "#rust");
1215    }
1216
1217    #[test]
1218    fn parse_line_skips_label_inside_inline_code() {
1219        let parsed = ParsedLine::parse("use `#foo` here");
1220        let has_label = parsed
1221            .elements
1222            .iter()
1223            .any(|e| matches!(e.kind, ElementKind::Label));
1224        assert!(!has_label, "should not emit Label inside inline code");
1225    }
1226
1227    // ── New label-parity tests (F2, F3, F4) ──────────────────────────────────
1228
1229    #[test]
1230    fn parse_line_skips_label_inside_markdown_link() {
1231        let parsed = ParsedLine::parse("[see docs](#section) and #real");
1232        let labels: Vec<_> = parsed
1233            .elements
1234            .iter()
1235            .filter(|e| matches!(e.kind, ElementKind::Label))
1236            .collect();
1237        assert_eq!(
1238            labels.len(),
1239            1,
1240            "only #real should be a label, not #section in the link"
1241        );
1242        let l = labels[0];
1243        let span: String = "[see docs](#section) and #real"
1244            .chars()
1245            .skip(l.start_char)
1246            .take(l.end_char - l.start_char)
1247            .collect();
1248        assert_eq!(span, "#real");
1249    }
1250
1251    #[test]
1252    fn parse_line_skips_label_inside_link_display_text() {
1253        let parsed = ParsedLine::parse("[#todo](notes/project.md)");
1254        let has_label = parsed
1255            .elements
1256            .iter()
1257            .any(|e| matches!(e.kind, ElementKind::Label));
1258        assert!(
1259            !has_label,
1260            "hashtag inside link display text should not become Label"
1261        );
1262    }
1263
1264    #[test]
1265    fn parse_line_skips_label_after_label_char() {
1266        let parsed = ParsedLine::parse("foo#bar baz");
1267        let has_label = parsed
1268            .elements
1269            .iter()
1270            .any(|e| matches!(e.kind, ElementKind::Label));
1271        assert!(
1272            !has_label,
1273            "word#tag should not emit Label without word boundary"
1274        );
1275    }
1276
1277    #[test]
1278    fn parse_line_skips_label_for_double_hash() {
1279        // `##draft` is Markdown header territory, not a label — pin the
1280        // highlighter to the same rule the indexer enforces so a future
1281        // core relaxation cannot silently re-color this span.
1282        let parsed = ParsedLine::parse("##draft");
1283        let has_label = parsed
1284            .elements
1285            .iter()
1286            .any(|e| matches!(e.kind, ElementKind::Label));
1287        assert!(!has_label, "##draft should not emit Label");
1288    }
1289
1290    #[test]
1291    fn parse_line_skips_label_for_adjacent_hash_run() {
1292        // `#tag#more` — adjacent `#` invalidates both halves at the index
1293        // level; the highlighter must agree to avoid suggesting tags that
1294        // will never appear in the labels table.
1295        let parsed = ParsedLine::parse("#tag#more");
1296        let labels: Vec<_> = parsed
1297            .elements
1298            .iter()
1299            .filter(|e| matches!(e.kind, ElementKind::Label))
1300            .collect();
1301        assert!(
1302            labels.is_empty(),
1303            "#tag#more should not emit Label, got {:?}",
1304            labels
1305        );
1306    }
1307
1308    #[test]
1309    fn parse_buffer_skips_label_inside_fenced_block() {
1310        let buffer = vec![
1311            "before".to_string(),
1312            "```".to_string(),
1313            "#inside".to_string(),
1314            "```".to_string(),
1315            "after #outside".to_string(),
1316        ];
1317        let lines = ParsedBuffer::parse(&buffer).lines;
1318        let inside_labels: Vec<_> = lines[2]
1319            .elements
1320            .iter()
1321            .filter(|e| matches!(e.kind, ElementKind::Label))
1322            .collect();
1323        assert!(
1324            inside_labels.is_empty(),
1325            "no Label emitted for hashtags in fenced blocks"
1326        );
1327
1328        let outside_labels: Vec<_> = lines[4]
1329            .elements
1330            .iter()
1331            .filter(|e| matches!(e.kind, ElementKind::Label))
1332            .collect();
1333        assert_eq!(outside_labels.len(), 1, "#outside still extracted");
1334    }
1335
1336    #[test]
1337    fn parse_range_full_equals_parse() {
1338        let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
1339        let full = ParsedBuffer::parse(&lines);
1340        let range_full = ParsedBuffer::parse_range(&lines, 0..lines.len());
1341        assert_eq!(full.lines.len(), range_full.lines.len());
1342        assert_eq!(full.kinds, range_full.kinds);
1343        for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
1344            assert_eq!(a.content_vis, b.content_vis);
1345            assert_eq!(a.elements.len(), b.elements.len());
1346        }
1347    }
1348
1349    #[test]
1350    fn parse_range_paragraph_only_slice() {
1351        let lines: Vec<String> = vec![
1352            "intro paragraph".into(),
1353            "".into(),
1354            "middle line".into(),
1355            "".into(),
1356            "outro".into(),
1357        ];
1358        let slice = ParsedBuffer::parse_range(&lines, 2..3);
1359        assert_eq!(slice.lines.len(), 1);
1360        assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
1361    }
1362
1363    #[test]
1364    fn splice_replaces_range() {
1365        let mut pb = ParsedBuffer::parse(&["alpha".into(), "beta".into(), "gamma".into()]);
1366        let replacement = ParsedBuffer::parse(&["BETA-NEW".into()]);
1367        let replacement_kind = replacement.kinds[0];
1368        pb.splice(1..2, replacement);
1369        assert_eq!(pb.lines.len(), 3);
1370        assert_eq!(pb.kinds.len(), 3);
1371        assert_eq!(
1372            pb.kinds[1], replacement_kind,
1373            "replacement landed at the wrong index"
1374        );
1375    }
1376
1377    #[cfg(debug_assertions)]
1378    #[test]
1379    #[should_panic(expected = "splice")]
1380    fn splice_panics_on_length_mismatch_in_debug() {
1381        let mut pb = ParsedBuffer::parse(&["a".into(), "b".into()]);
1382        let too_short = ParsedBuffer::parse(&["X".into()]);
1383        pb.splice(0..2, too_short);
1384    }
1385
1386    // ── V2 lazy_depth tracking ───────────────────────────────────────────────
1387
1388    /// CORRECTED FROM SPEC: tasks.md 2.1 asserted `[1, 1, 1, 0]`,
1389    /// claiming blockquote lazy-extends across blanks. This is
1390    /// incorrect per CommonMark §5.1 — a blank line ENDS a
1391    /// blockquote (see Example 209). Pulldown closes the blockquote
1392    /// at the first blank, so lazy_depth drops there. The §5.1 lazy
1393    /// "paragraph continuation" cited in the spec is about non-`>`
1394    /// lines continuing an OPEN paragraph (still on the same line
1395    /// run), not extending the blockquote across blanks.
1396    #[test]
1397    fn lazy_depth_blockquote_closes_at_first_blank() {
1398        let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
1399        let pb = ParsedBuffer::parse(&lines);
1400        assert_eq!(
1401            pb.lazy_depth,
1402            vec![1, 0, 0, 0],
1403            "blockquote closes at first blank per CommonMark §5.1; got {:?}",
1404            pb.lazy_depth,
1405        );
1406    }
1407
1408    /// IndentedCode lazy-extends across a blank row joining two
1409    /// indented chunks (CommonMark §4.4). All three rows must
1410    /// report lazy_depth ≥ 1 — including the last content row, so
1411    /// the v2 structural guard catches edits anywhere inside the
1412    /// block.
1413    #[test]
1414    fn lazy_depth_indented_code_across_blanks() {
1415        let lines: Vec<String> = vec!["    code".into(), "".into(), "    more".into()];
1416        let pb = ParsedBuffer::parse(&lines);
1417        assert_eq!(
1418            pb.lazy_depth,
1419            vec![1, 1, 1],
1420            "indented code multi-chunk should keep lazy_depth > 0 across the blank \
1421             AND through the last content row; got {:?}",
1422            pb.lazy_depth,
1423        );
1424    }
1425
1426    /// Fenced code blocks are NOT lazy-continuable — their closing
1427    /// fence is a hard terminator. lazy_depth must remain 0 on
1428    /// every row.
1429    #[test]
1430    fn lazy_depth_fenced_code_does_not_count() {
1431        let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
1432        let pb = ParsedBuffer::parse(&lines);
1433        assert_eq!(
1434            pb.lazy_depth,
1435            vec![0, 0, 0, 0],
1436            "fenced code is not lazy-continuable; got {:?}",
1437            pb.lazy_depth,
1438        );
1439    }
1440
1441    /// Regression: BlockQuote followed by a trailing blank row must
1442    /// drop `lazy_depth` AT the blank row, not past it. The buggy
1443    /// past-EOF heuristic in `byte_to_row_col_unclamped` mis-fired
1444    /// for End events landing on the START of a trailing empty row
1445    /// (binary_search returned `Ok(r)` with `r < lines.len()` and a
1446    /// 0-length row), shunting the decrement into the past-array
1447    /// sentinel slot and leaving `lazy_depth[r]` elevated. That in
1448    /// turn suppressed the legitimate reset boundary at row r and
1449    /// forced full rebuilds on every edit adjacent to a trailing
1450    /// blank.
1451    #[test]
1452    fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
1453        let lines: Vec<String> = vec!["> a".into(), "".into()];
1454        let pb = ParsedBuffer::parse(&lines);
1455        assert_eq!(
1456            pb.lazy_depth,
1457            vec![1, 0],
1458            "blockquote must close at the trailing blank; got {:?}",
1459            pb.lazy_depth,
1460        );
1461        assert!(
1462            pb.reset_boundaries.contains(&1),
1463            "the trailing blank at row 1 must be a reset boundary; got {:?}",
1464            pb.reset_boundaries,
1465        );
1466    }
1467
1468    /// Boundary detection must skip rows inside a lazy-continuable
1469    /// block. Using the IndentedCode multi-chunk fixture (the
1470    /// canonical §4.4 case) every row has lazy_depth > 0, so no
1471    /// interior boundary can land. Only the sentinels remain.
1472    ///
1473    /// CORRECTED FROM SPEC: tasks.md 2.4 used the blockquote
1474    /// fixture from 2.1, which does NOT produce interior
1475    /// lazy_depth > 0 rows (blanks end the blockquote). The
1476    /// IndentedCode multi-chunk fixture is the correct one for
1477    /// this invariant.
1478    #[test]
1479    fn boundaries_skip_rows_inside_lazy_block() {
1480        let lines: Vec<String> = vec!["    code".into(), "".into(), "    more".into()];
1481        let pb = ParsedBuffer::parse(&lines);
1482        assert_eq!(
1483            pb.reset_boundaries,
1484            vec![0, lines.len()],
1485            "no boundary should land on a blank row inside the open indented-code block; \
1486             got {:?}",
1487            pb.reset_boundaries,
1488        );
1489    }
1490}