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