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
16pub(super) const PARSER_OPTIONS: Options = Options::ENABLE_STRIKETHROUGH;
18
19pub(super) const TAB_STOP: usize = 4;
23
24pub(super) fn tab_width_at(col: usize) -> usize {
26 TAB_STOP - (col % TAB_STOP)
27}
28
29pub(super) fn string_display_width(s: &str) -> usize {
32 s.graphemes(true).map(cluster_display_width).sum()
33}
34
35pub(super) fn blockquote_gutter(depth: u8) -> String {
41 let mut s = "│".repeat(depth as usize);
42 s.push(' ');
43 s
44}
45
46pub(super) fn blockquote_gutter_width(depth: u8) -> usize {
50 depth as usize + 1
51}
52
53pub(super) fn raw_display_width(line: &str) -> usize {
57 let mut col = 0usize;
58 for g in line.graphemes(true) {
59 col += cluster_width_at(g, col);
60 }
61 col
62}
63
64pub(super) fn cluster_width_at(cluster: &str, col: usize) -> usize {
69 if cluster == "\t" {
70 tab_width_at(col)
71 } else {
72 cluster_display_width(cluster)
73 }
74}
75
76pub(super) fn cluster_display_width(cluster: &str) -> usize {
90 use unicode_width::UnicodeWidthStr;
91 cluster.width()
92}
93
94#[derive(Debug, Clone, PartialEq)]
95pub struct Element {
96 pub start_char: usize,
97 pub end_char: usize,
98 pub kind: ElementKind,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub enum ElementKind {
103 Bold,
104 Italic,
105 Strikethrough,
106 InlineCode,
107 Link,
108 HeadingH1,
109 HeadingH2,
110 HeadingH3,
111 Blockquote,
112 WikiLink,
113 Image,
114 Label,
115}
116
117#[derive(Debug, Clone)]
122pub struct ImagePlaceholder {
123 pub start_char: usize,
124 pub end_char: usize,
125 pub placeholder: String,
126 pub placeholder_width: usize,
127}
128
129#[derive(Debug, Clone)]
133pub struct ParsedLine {
134 pub elements: Vec<Element>,
135 pub content_vis: Vec<bool>,
137 elem_vis: Vec<bool>,
140 elem_index: Vec<u16>,
143 modifier_mask: Vec<u8>,
149 list_sigil_end: Option<usize>,
152 pub image_placeholders: Vec<ImagePlaceholder>,
156 blockquote_depth: Option<u8>,
162}
163
164impl ParsedLine {
165 pub fn parse(line: &str) -> Self {
175 let owned = line.to_string();
176 if needs_synthetic_list_parent(line) {
177 ParsedBuffer::parse(&["- ".to_string(), owned])
180 .lines
181 .pop()
182 .expect("ParsedBuffer::parse returns one row per input line")
183 } else {
184 ParsedBuffer::parse(std::slice::from_ref(&owned))
185 .lines
186 .pop()
187 .expect("ParsedBuffer::parse always returns at least one ParsedLine")
188 }
189 }
190
191 pub fn elem_at(&self, pos: usize) -> Option<usize> {
193 self.elem_index.get(pos).and_then(|&tag| {
194 if tag == 0 {
195 None
196 } else {
197 Some((tag as usize) - 1)
198 }
199 })
200 }
201
202 pub fn in_any_element(&self, pos: usize) -> bool {
204 self.elem_vis.get(pos).copied().unwrap_or(false)
205 }
206
207 pub(super) fn modifiers_at(&self, pos: usize) -> u8 {
210 self.modifier_mask.get(pos).copied().unwrap_or(0)
211 }
212
213 pub fn heading_sigil_end(&self) -> Option<usize> {
220 self.elements
221 .iter()
222 .position(|e| {
223 matches!(
224 e.kind,
225 ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
226 )
227 })
228 .map(|idx| self.first_content_char(idx))
229 }
230
231 fn first_content_char(&self, elem_idx: usize) -> usize {
241 let e = &self.elements[elem_idx];
242 for i in e.start_char..e.end_char {
243 if i < self.content_vis.len() && self.content_vis[i] {
244 return i;
245 }
246 if self.elem_at(i).is_some_and(|inner| inner != elem_idx) {
247 return i;
248 }
249 }
250 e.end_char
251 }
252
253 pub fn list_sigil_end(&self) -> Option<usize> {
256 self.list_sigil_end
257 }
258
259 pub fn blockquote_depth(&self) -> Option<u8> {
261 self.blockquote_depth
262 }
263
264 pub fn blockquote_sigil_end(&self) -> Option<usize> {
269 self.elements
270 .iter()
271 .position(|e| e.kind == ElementKind::Blockquote)
272 .map(|idx| self.first_content_char(idx))
273 }
274
275 #[cfg(debug_assertions)]
280 pub(super) fn debug_assert_eq_to(&self, other: &Self, row: usize) {
281 assert_eq!(
282 self.content_vis, other.content_vis,
283 "row {row} content_vis diverge"
284 );
285 assert_eq!(self.elem_vis, other.elem_vis, "row {row} elem_vis diverge");
286 assert_eq!(
287 self.elem_index, other.elem_index,
288 "row {row} elem_index diverge"
289 );
290 assert_eq!(
291 self.list_sigil_end, other.list_sigil_end,
292 "row {row} list_sigil_end diverge"
293 );
294 assert_eq!(
295 self.blockquote_depth, other.blockquote_depth,
296 "row {row} blockquote_depth diverge"
297 );
298 assert_eq!(
299 self.elements.len(),
300 other.elements.len(),
301 "row {row} elements.len() diverge"
302 );
303 }
304}
305
306fn needs_synthetic_list_parent(line: &str) -> bool {
311 let trimmed = line.trim_start_matches([' ', '\t']);
312 if trimmed.len() == line.len() {
313 return false; }
315 list_marker_len(trimmed).is_some()
316}
317
318pub(super) fn leading_ws_byte_len(line: &str) -> usize {
326 line.bytes()
327 .take_while(|b| *b == b' ' || *b == b'\t')
328 .count()
329}
330
331pub(super) fn tag_to_kind(tag: &Tag) -> Option<ElementKind> {
336 Some(match tag {
337 Tag::Strong => ElementKind::Bold,
338 Tag::Emphasis => ElementKind::Italic,
339 Tag::Strikethrough => ElementKind::Strikethrough,
340 Tag::Link { .. } => ElementKind::Link,
341 Tag::BlockQuote(_) => ElementKind::Blockquote,
342 Tag::Heading { level, .. } => match level {
343 HeadingLevel::H1 => ElementKind::HeadingH1,
344 HeadingLevel::H2 => ElementKind::HeadingH2,
345 _ => ElementKind::HeadingH3,
346 },
347 _ => return None,
348 })
349}
350
351pub(super) fn list_marker_len(s: &str) -> Option<usize> {
352 if s.starts_with("- ") || s.starts_with("* ") || s.starts_with("+ ") {
353 return Some(2);
354 }
355 let bytes = s.as_bytes();
356 let mut i = 0;
357 while i < bytes.len() && bytes[i].is_ascii_digit() {
358 i += 1;
359 }
360 if i > 0 && i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b' ' {
361 Some(i + 2)
362 } else {
363 None
364 }
365}
366
367pub(super) const MOD_BOLD: u8 = 1 << 0;
369pub(super) const MOD_ITALIC: u8 = 1 << 1;
370pub(super) const MOD_STRIKE: u8 = 1 << 2;
371
372pub(super) fn modifier_bit(kind: ElementKind) -> u8 {
374 match kind {
375 ElementKind::Bold => MOD_BOLD,
376 ElementKind::Italic => MOD_ITALIC,
377 ElementKind::Strikethrough => MOD_STRIKE,
378 _ => 0,
379 }
380}
381
382pub(super) fn mask_to_modifier(mask: u8) -> Modifier {
384 let mut m = Modifier::empty();
385 if mask & MOD_BOLD != 0 {
386 m |= Modifier::BOLD;
387 }
388 if mask & MOD_ITALIC != 0 {
389 m |= Modifier::ITALIC;
390 }
391 if mask & MOD_STRIKE != 0 {
392 m |= Modifier::CROSSED_OUT;
393 }
394 m
395}
396
397pub(super) fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
398 match kind {
399 None => {
400 if is_sigil_region {
401 Style::default().fg(theme.gray.to_ratatui())
402 } else {
403 Style::default().fg(theme.fg.to_ratatui())
404 }
405 }
406 Some(ElementKind::Bold) => Style::default()
407 .fg(theme.accent.to_ratatui())
408 .add_modifier(Modifier::BOLD),
409 Some(ElementKind::Italic) => Style::default()
410 .fg(theme.fg_secondary.to_ratatui())
411 .add_modifier(Modifier::ITALIC),
412 Some(ElementKind::Strikethrough) => Style::default()
413 .fg(theme.fg_secondary.to_ratatui())
414 .add_modifier(Modifier::CROSSED_OUT),
415 Some(ElementKind::InlineCode) => Style::default()
416 .fg(theme.aqua.to_ratatui())
417 .bg(theme.bg_soft.to_ratatui()),
418 Some(ElementKind::Link) => Style::default()
419 .fg(theme.accent.to_ratatui())
420 .add_modifier(Modifier::UNDERLINED),
421 Some(ElementKind::Image) => Style::default()
422 .fg(theme.accent.to_ratatui())
423 .add_modifier(Modifier::ITALIC),
424 Some(ElementKind::HeadingH1) | Some(ElementKind::HeadingH2) => {
426 if is_sigil_region {
427 Style::default().fg(theme.gray.to_ratatui())
428 } else {
429 Style::default()
430 .fg(theme.fg_bright.to_ratatui())
431 .add_modifier(Modifier::BOLD)
432 }
433 }
434 Some(ElementKind::HeadingH3) => {
435 if is_sigil_region {
436 Style::default().fg(theme.gray.to_ratatui())
437 } else {
438 Style::default()
439 .fg(theme.yellow.to_ratatui())
440 .add_modifier(Modifier::BOLD)
441 }
442 }
443 Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
444 Some(ElementKind::WikiLink) => Style::default()
446 .fg(theme.blue.to_ratatui())
447 .add_modifier(Modifier::UNDERLINED),
448 Some(ElementKind::Label) => Style::default()
449 .fg(theme.color_tag.to_ratatui())
450 .add_modifier(Modifier::BOLD),
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::super::parse_incremental::LineConstructKind;
457 use super::*;
458 use ratatui::style::Modifier;
459 fn t() -> Theme {
460 Theme::default()
461 }
462 fn text(spans: &[Span]) -> String {
463 spans.iter().map(|s| s.content.as_ref()).collect()
464 }
465
466 #[test]
467 fn cluster_display_width_emoji_presentation_sequences() {
468 assert_eq!(cluster_display_width("\u{1F1EA}\u{1F1F8}"), 2, "flag 🇪🇸");
472 assert_eq!(
473 cluster_display_width("\u{2764}\u{FE0F}"),
474 2,
475 "heart ❤️ (VS16)"
476 );
477 assert_eq!(cluster_display_width("1\u{FE0F}\u{20E3}"), 2, "keycap 1️⃣");
478 assert_eq!(cluster_display_width("\u{3042}"), 2, "CJK あ");
480 assert_eq!(cluster_display_width("a"), 1, "ascii");
481 assert_eq!(cluster_display_width("e\u{0301}"), 1, "e + combining acute");
482 }
483
484 #[test]
485 fn cluster_display_width_zero_width_clusters_are_zero() {
486 assert_eq!(cluster_display_width("\u{200B}"), 0, "ZWSP");
489 assert_eq!(cluster_display_width("\u{00AD}"), 0, "soft hyphen");
490 assert_eq!(cluster_display_width("\u{200C}"), 0, "ZWNJ");
491 assert_eq!(cluster_display_width("\u{FEFF}"), 0, "BOM");
492 assert_eq!(cluster_display_width("\u{0301}"), 0, "lone combining acute");
493 }
494
495 #[test]
496 fn blockquote_lazy_continuation_carries_depth() {
497 let buf = ParsedBuffer::parse(&["> first".to_string(), "second".to_string()]);
502 assert_eq!(buf.lines[0].blockquote_depth(), Some(1));
503 assert_eq!(
504 buf.lines[1].blockquote_depth(),
505 Some(1),
506 "lazy continuation line must carry the blockquote depth"
507 );
508
509 let nested = ParsedBuffer::parse(&[">> a".to_string(), "b".to_string()]);
511 assert_eq!(nested.lines[0].blockquote_depth(), Some(2));
512 assert_eq!(nested.lines[1].blockquote_depth(), Some(2));
513
514 let ended =
516 ParsedBuffer::parse(&["> first".to_string(), String::new(), "plain".to_string()]);
517 assert_eq!(ended.lines[0].blockquote_depth(), Some(1));
518 assert_eq!(ended.lines[2].blockquote_depth(), None);
519 }
520
521 #[test]
522 fn indented_code_excludes_trailing_blank_keeps_interior() {
523 use super::super::parse_incremental::LineConstructKind::{Blank, IndentedCode, Plain};
524 let kinds = |lines: &[&str]| {
525 let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
526 ParsedBuffer::parse(&owned).kinds
527 };
528 assert_eq!(
530 kinds(&[" code", "", "outro"]),
531 vec![IndentedCode, Blank, Plain]
532 );
533 assert_eq!(
535 kinds(&[" a", "", " b"]),
536 vec![IndentedCode, IndentedCode, IndentedCode]
537 );
538 assert_eq!(
540 kinds(&[" a", "", "", "outro"]),
541 vec![IndentedCode, Blank, Blank, Plain]
542 );
543 assert_eq!(
546 kinds(&[" Line 1", " Line 2", "Line 3"]),
547 vec![IndentedCode, IndentedCode, Plain]
548 );
549 assert_eq!(
551 kinds(&[" line 1", " line 2", "", "", " line 3"]),
552 vec![
553 IndentedCode,
554 IndentedCode,
555 IndentedCode,
556 IndentedCode,
557 IndentedCode
558 ]
559 );
560 }
561
562 #[test]
563 fn gutter_width_matches_rendered() {
564 for d in 1u8..=4 {
567 assert_eq!(
568 blockquote_gutter_width(d),
569 string_display_width(&blockquote_gutter(d)),
570 "gutter width/string disagree at depth {d}"
571 );
572 }
573 }
574
575 #[test]
576 fn blockquote_depth_and_sigil_end() {
577 let p = ParsedLine::parse("> hello");
578 assert_eq!(p.blockquote_depth(), Some(1));
579 assert_eq!(p.blockquote_sigil_end(), Some(2));
581
582 let p2 = ParsedLine::parse(">> deep");
583 assert_eq!(p2.blockquote_depth(), Some(2));
584
585 let plain = ParsedLine::parse("not a quote");
586 assert_eq!(plain.blockquote_depth(), None);
587 assert_eq!(plain.blockquote_sigil_end(), None);
588 }
589 #[test]
590 fn parse_bold_range() {
591 let e = MarkdownSpanner::parse_elements("**bold**");
592 let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
593 assert_eq!((b.start_char, b.end_char), (0, 8));
594 }
595 #[test]
596 fn parse_italic() {
597 assert!(
598 MarkdownSpanner::parse_elements("*hi*")
599 .iter()
600 .any(|e| e.kind == ElementKind::Italic)
601 );
602 }
603 #[test]
604 fn parse_strikethrough() {
605 let e = MarkdownSpanner::parse_elements("~~gone~~");
606 let s = e
607 .iter()
608 .find(|x| x.kind == ElementKind::Strikethrough)
609 .unwrap();
610 assert_eq!((s.start_char, s.end_char), (0, 8));
611 }
612 #[test]
613 fn strikethrough_renders_with_crossed_out_modifier() {
614 let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
615 assert_eq!(text(&s), "gone");
616 assert!(
617 s.iter()
618 .any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
619 );
620 }
621 #[test]
622 fn parse_inline_code() {
623 assert!(
624 MarkdownSpanner::parse_elements("`x`")
625 .iter()
626 .any(|e| e.kind == ElementKind::InlineCode)
627 );
628 }
629 #[test]
630 fn parse_link() {
631 assert!(
632 MarkdownSpanner::parse_elements("[t](u)")
633 .iter()
634 .any(|e| e.kind == ElementKind::Link)
635 );
636 }
637
638 #[test]
639 fn parse_image_emits_image_element_and_placeholder() {
640 let line = "see  here";
641 let parsed = ParsedLine::parse(line);
642 let img = parsed
643 .elements
644 .iter()
645 .find(|e| e.kind == ElementKind::Image)
646 .expect("image element");
647 assert_eq!(line.chars().nth(img.start_char), Some('!'));
648 assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
649 let ph = parsed
650 .image_placeholders
651 .iter()
652 .find(|p| p.start_char == img.start_char)
653 .expect("placeholder for image");
654 assert_eq!(ph.placeholder, "[img.png]");
655 for pos in img.start_char..img.end_char {
656 assert!(
657 !parsed.content_vis[pos],
658 "char {pos} should be hidden inside image span"
659 );
660 }
661 }
662
663 #[test]
664 fn render_image_substitutes_placeholder_text() {
665 let line = "before  after";
666 let parsed = ParsedLine::parse(line);
667 let spans =
668 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
669 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
670 assert!(
671 rendered.contains("[pic.gif]"),
672 "rendered text {rendered:?} should include placeholder"
673 );
674 assert!(
675 !rendered.contains("![alt]"),
676 "raw image syntax should not appear in rendered output: {rendered:?}"
677 );
678 }
679
680 #[test]
681 fn render_image_with_empty_alt_uses_filename() {
682 let line = "";
683 let parsed = ParsedLine::parse(line);
684 let spans =
685 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
686 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
687 assert_eq!(rendered, "[image.png]");
688 }
689
690 #[test]
691 fn rendered_cursor_col_accounts_for_placeholder_width() {
692 let line = "a  b";
694 let parsed = ParsedLine::parse(line);
695 let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
696 line,
697 &parsed,
698 0,
699 "a  b".chars().count(), true,
701 false,
702 );
703 assert_eq!(after_placeholder, 11);
705 }
706 #[test]
707 fn parse_h1() {
708 assert!(
709 MarkdownSpanner::parse_elements("# T")
710 .iter()
711 .any(|e| e.kind == ElementKind::HeadingH1)
712 );
713 }
714 #[test]
715 fn parse_h2() {
716 assert!(
717 MarkdownSpanner::parse_elements("## T")
718 .iter()
719 .any(|e| e.kind == ElementKind::HeadingH2)
720 );
721 }
722 #[test]
723 fn parse_h3() {
724 assert!(
725 MarkdownSpanner::parse_elements("### T")
726 .iter()
727 .any(|e| e.kind == ElementKind::HeadingH3)
728 );
729 }
730 #[test]
731 fn force_raw_no_styling() {
732 let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
733 assert_eq!(text(&s), "**x**");
734 assert!(
735 !s.iter()
736 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
737 );
738 }
739 #[test]
740 fn plain_text_passthrough() {
741 let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
742 assert_eq!(text(&s), "hi");
743 }
744 #[test]
745 fn bold_without_cursor_hides_markers() {
746 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
747 assert_eq!(text(&s), "bold");
748 assert!(
749 s.iter()
750 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
751 );
752 }
753 #[test]
754 fn bold_cursor_inside_shows_raw() {
755 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
756 assert_eq!(text(&s), "**bold**");
757 }
758 #[test]
759 fn bold_cursor_outside_stays_rendered() {
760 let line = "hello **bold** world";
761 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
762 assert!(!text(&s).contains("**"));
763 }
764 #[test]
765 fn italic_cursor_inside_shows_raw() {
766 let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
767 assert_eq!(text(&s), "*hi*");
768 }
769 #[test]
770 fn inline_code_hides_backticks() {
771 let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
772 assert_eq!(text(&s), "x");
773 }
774 #[test]
775 fn h1_first_line_contains_hash() {
776 let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
777 assert!(text(&s).contains('#'));
778 assert!(text(&s).contains('T'));
779 }
780 #[test]
781 fn continuation_line_no_hash() {
782 let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
783 assert!(!text(&s).contains('#'));
784 }
785 #[test]
786 fn unordered_list_shows_marker() {
787 let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
788 assert!(
789 text(&s).starts_with("- "),
790 "expected '- item', got '{}'",
791 text(&s)
792 );
793 assert!(text(&s).contains("item"));
794 }
795 #[test]
796 fn ordered_list_shows_marker() {
797 let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
798 assert!(
799 text(&s).starts_with("1. "),
800 "expected '1. item', got '{}'",
801 text(&s)
802 );
803 }
804 #[test]
805 fn nested_list_4space_link_rendered() {
806 let line = " - [my link](url)";
808 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
809 assert!(
812 s.iter()
813 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
814 "link text should be underlined on a 4-space-indented nested list item"
815 );
816 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
817 assert!(
818 rendered.contains("my link"),
819 "link display text should be visible; got {:?}",
820 rendered
821 );
822 assert!(
823 !rendered.contains("](url)"),
824 "link URL sigil should be hidden; got {:?}",
825 rendered
826 );
827 }
828
829 #[test]
830 fn nested_list_tab_bold_rendered() {
831 let line = "\t- **bold nested**";
832 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
833 assert!(
834 s.iter()
835 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
836 "bold text should be styled on a tab-indented nested list item"
837 );
838 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
839 assert!(
840 !rendered.contains("**"),
841 "bold markers should be hidden; got {:?}",
842 rendered
843 );
844 }
845
846 #[test]
847 fn nested_list_4space_wikilink_rendered() {
848 let line = " - [[Target Note]]";
849 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
850 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
851 assert!(
852 !rendered.contains("[["),
853 "wikilink brackets should be hidden; got {:?}",
854 rendered
855 );
856 assert!(
857 rendered.contains("Target Note"),
858 "wikilink target text should render; got {:?}",
859 rendered
860 );
861 }
862
863 #[test]
864 fn nested_list_2space_still_renders_link() {
865 let line = " - [link](url)";
867 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
868 assert!(
869 s.iter()
870 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
871 );
872 }
873
874 #[test]
875 fn empty_heading_shows_hash_sigil() {
876 let line = "# ";
877 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
878 assert!(
879 text(&s).contains('#'),
880 "hash sigil should render in empty heading"
881 );
882 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
883 assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
884 }
885 #[test]
886 fn empty_heading_hash_only_shows() {
887 let line = "#";
888 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
889 assert!(text(&s).contains('#'));
890 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
891 assert_eq!(col, 1);
892 }
893 #[test]
894 fn heading_trailing_spaces_are_rendered() {
895 let line = "# Hello ";
896 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
897 assert_eq!(
898 text(&s),
899 "# Hello ",
900 "trailing spaces in heading should render"
901 );
902 }
903 #[test]
904 fn heading_trailing_spaces_cursor_col_correct() {
905 let line = "# Hello ";
906 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
908 assert_eq!(
909 col, 9,
910 "cursor in trailing space of heading should map to rendered col 9"
911 );
912 }
913 #[test]
914 fn trailing_spaces_are_rendered() {
915 let line = "hello ";
916 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
917 assert_eq!(text(&s), "hello ");
918 }
919 #[test]
920 fn trailing_spaces_cursor_col_correct() {
921 let line = "hello ";
922 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
923 assert_eq!(col, 7);
924 }
925 #[test]
926 fn list_marker_on_continuation_line_hidden() {
927 let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
928 assert!(!text(&s).starts_with("- "));
929 }
930 #[test]
931 fn parsed_line_heading_sigil_end_empty_heading() {
932 let p = ParsedLine::parse("#");
934 assert_eq!(p.heading_sigil_end(), Some(1));
935 }
936 #[test]
937 fn parsed_line_heading_sigil_end_with_content() {
938 let p = ParsedLine::parse("# T");
940 assert_eq!(p.heading_sigil_end(), Some(2));
941 }
942 #[test]
943 fn parsed_line_reuse_matches_individual() {
944 let line = "**hello** world";
945 let parsed = ParsedLine::parse(line);
946 let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
947 let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
948 assert_eq!(
949 s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
950 s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
951 );
952 }
953
954 #[test]
957 fn parse_wikilink() {
958 let e = MarkdownSpanner::parse_elements("[[My Note]]");
959 let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
960 assert_eq!((wl.start_char, wl.end_char), (0, 11));
961 }
962
963 #[test]
964 fn wikilink_without_cursor_hides_brackets() {
965 let line = "[[My Note]]";
966 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
967 assert_eq!(text(&s), "My Note");
968 assert!(
969 s.iter()
970 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
971 );
972 }
973
974 #[test]
975 fn wikilink_cursor_inside_shows_brackets() {
976 let line = "[[My Note]]";
977 let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
979 assert_eq!(text(&s), "[[My Note]]");
980 }
981
982 #[test]
983 fn wikilink_cursor_outside_hides_brackets() {
984 let line = "hello [[My Note]] world";
985 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
986 assert!(!text(&s).contains("[["));
987 assert!(!text(&s).contains("]]"));
988 }
989
990 #[test]
991 fn wikilink_in_heading_rendered() {
992 let line = "# See [[Topic]]";
993 let e = MarkdownSpanner::parse_elements(line);
994 assert!(
995 e.iter().any(|x| x.kind == ElementKind::WikiLink),
996 "wikilink inside heading should produce a WikiLink element"
997 );
998 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
999 assert_eq!(text(&s), "# See Topic", "wikilink brackets hidden, # kept");
1000 }
1001
1002 #[test]
1003 fn heading_with_link_does_not_leak_bracket() {
1004 let line = "# [text](http://x)";
1005 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1006 assert_eq!(text(&s), "# text");
1009 }
1010
1011 #[test]
1012 fn heading_with_bold_does_not_leak_asterisk() {
1013 let line = "# **bold**";
1014 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1015 assert_eq!(
1016 text(&s),
1017 "# bold",
1018 "leading * must not leak into heading sigil"
1019 );
1020 }
1021
1022 #[test]
1023 fn bold_wikilink_is_bold() {
1024 let line = "**[[Topic]]**";
1025 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1026 assert_eq!(text(&s), "Topic");
1027 assert!(
1028 s.iter()
1029 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)
1030 && sp.content.contains("Topic")),
1031 "wikilink wrapped in ** must render bold"
1032 );
1033 }
1034
1035 #[test]
1036 fn italic_link_is_italic() {
1037 let line = "*[text](http://x)*";
1038 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1039 assert_eq!(text(&s), "text");
1040 assert!(
1041 s.iter()
1042 .any(|sp| sp.style.add_modifier.contains(Modifier::ITALIC)
1043 && sp.content.contains("text")),
1044 "link wrapped in * must render italic"
1045 );
1046 }
1047
1048 #[test]
1049 fn bold_italic_wikilink_is_bold_and_italic() {
1050 let line = "***[[Topic]]***";
1051 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1052 assert_eq!(text(&s), "Topic");
1053 assert!(
1054 s.iter().any(|sp| {
1055 sp.content.contains("Topic")
1056 && sp.style.add_modifier.contains(Modifier::BOLD)
1057 && sp.style.add_modifier.contains(Modifier::ITALIC)
1058 }),
1059 "wikilink in *** *** must render both bold and italic"
1060 );
1061 }
1062
1063 #[test]
1064 fn bold_italic_plain_text() {
1065 let line = "***text***";
1066 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1067 assert_eq!(text(&s), "text");
1068 assert!(
1069 s.iter().any(|sp| {
1070 sp.content.contains("text")
1071 && sp.style.add_modifier.contains(Modifier::BOLD)
1072 && sp.style.add_modifier.contains(Modifier::ITALIC)
1073 }),
1074 "*** *** must render both bold and italic"
1075 );
1076 }
1077
1078 #[test]
1079 fn wikilink_mid_sentence() {
1080 let line = "See [[Topic]] for details";
1081 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1082 assert_eq!(text(&s), "See Topic for details");
1083 }
1084
1085 #[test]
1086 fn wikilink_cursor_col_accounts_for_brackets() {
1087 let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
1090 assert_eq!(col, 2);
1091
1092 let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
1096 assert_eq!(col2, 0);
1097 }
1098
1099 #[test]
1100 fn buffer_parse_nested_list_under_parent() {
1101 let lines = vec".to_string(),
1105 ];
1106 let parsed = ParsedBuffer::parse(&lines).lines;
1107 assert_eq!(parsed.len(), 2);
1108
1109 assert_eq!(parsed[0].list_sigil_end(), Some(2));
1111
1112 assert_eq!(
1114 parsed[1].list_sigil_end(),
1115 Some(6),
1116 "child's sigil_end should be after ' - ' (6 chars)"
1117 );
1118
1119 assert!(
1121 parsed[1]
1122 .elements
1123 .iter()
1124 .any(|e| e.kind == ElementKind::Link),
1125 "nested list item should contain a Link element"
1126 );
1127 }
1128
1129 #[test]
1130 fn buffer_parse_standalone_2space_list_still_works() {
1131 let lines = vec".to_string()];
1133 let parsed = ParsedBuffer::parse(&lines).lines;
1134 assert!(
1135 parsed[0]
1136 .elements
1137 .iter()
1138 .any(|e| e.kind == ElementKind::Link)
1139 );
1140 assert_eq!(parsed[0].list_sigil_end(), Some(4));
1141 }
1142
1143 #[test]
1144 fn buffer_parse_top_level_unchanged() {
1145 let lines = vec".to_string()];
1147 let parsed = ParsedBuffer::parse(&lines).lines;
1148 assert!(
1149 parsed[0]
1150 .elements
1151 .iter()
1152 .any(|e| e.kind == ElementKind::Link)
1153 );
1154 assert_eq!(parsed[0].list_sigil_end(), Some(2));
1155 }
1156
1157 #[test]
1158 fn buffer_parse_empty_lines_preserved() {
1159 let lines = vec![
1160 "# Title".to_string(),
1161 String::new(),
1162 "paragraph".to_string(),
1163 ];
1164 let parsed = ParsedBuffer::parse(&lines).lines;
1165 assert_eq!(parsed.len(), 3);
1166 assert_eq!(parsed[1].elements.len(), 0);
1167 assert_eq!(parsed[1].content_vis.len(), 0);
1168 }
1169
1170 #[test]
1171 fn buffer_parse_ordered_nested_list() {
1172 let lines = vec!["1. first".to_string(), " 1. nested".to_string()];
1173 let parsed = ParsedBuffer::parse(&lines).lines;
1174 assert_eq!(parsed[0].list_sigil_end(), Some(3));
1175 assert_eq!(parsed[1].list_sigil_end(), Some(7));
1176 }
1177
1178 #[test]
1179 fn buffer_parse_setext_h1_spans_two_rows() {
1180 let lines = vec!["My Heading".to_string(), "==========".to_string()];
1186 let parsed = ParsedBuffer::parse(&lines).lines;
1187 assert!(
1188 parsed[0]
1189 .elements
1190 .iter()
1191 .any(|e| e.kind == ElementKind::HeadingH1),
1192 "setext underline must tag row 0 as HeadingH1"
1193 );
1194 assert!(
1195 parsed[1]
1196 .elements
1197 .iter()
1198 .any(|e| e.kind == ElementKind::HeadingH1),
1199 "setext underline must tag row 1 as HeadingH1"
1200 );
1201 assert!(
1203 parsed[1].content_vis.iter().all(|v| !v),
1204 "setext underline row has no content"
1205 );
1206 }
1207
1208 #[test]
1209 fn buffer_parse_multiline_blockquote() {
1210 let lines = vec!["> first line".to_string(), "> second line".to_string()];
1213 let parsed = ParsedBuffer::parse(&lines).lines;
1214 assert!(
1215 parsed[0]
1216 .elements
1217 .iter()
1218 .any(|e| e.kind == ElementKind::Blockquote),
1219 "row 0 must tag as Blockquote"
1220 );
1221 assert!(
1222 parsed[1]
1223 .elements
1224 .iter()
1225 .any(|e| e.kind == ElementKind::Blockquote),
1226 "row 1 must tag as Blockquote"
1227 );
1228 }
1229
1230 #[test]
1231 fn parse_line_emits_label_for_hashtag() {
1232 let line = "see #rust later";
1233 let parsed = ParsedLine::parse(line);
1234 let label = parsed
1235 .elements
1236 .iter()
1237 .find(|e| matches!(e.kind, ElementKind::Label));
1238 assert!(
1239 label.is_some(),
1240 "expected Label element: {:?}",
1241 parsed.elements
1242 );
1243 let l = label.unwrap();
1244 let span: String = line
1245 .chars()
1246 .skip(l.start_char)
1247 .take(l.end_char - l.start_char)
1248 .collect();
1249 assert_eq!(span, "#rust");
1250 }
1251
1252 #[test]
1253 fn parse_line_skips_label_inside_inline_code() {
1254 let parsed = ParsedLine::parse("use `#foo` here");
1255 let has_label = parsed
1256 .elements
1257 .iter()
1258 .any(|e| matches!(e.kind, ElementKind::Label));
1259 assert!(!has_label, "should not emit Label inside inline code");
1260 }
1261
1262 #[test]
1265 fn parse_line_skips_label_inside_markdown_link() {
1266 let parsed = ParsedLine::parse("[see docs](#section) and #real");
1267 let labels: Vec<_> = parsed
1268 .elements
1269 .iter()
1270 .filter(|e| matches!(e.kind, ElementKind::Label))
1271 .collect();
1272 assert_eq!(
1273 labels.len(),
1274 1,
1275 "only #real should be a label, not #section in the link"
1276 );
1277 let l = labels[0];
1278 let span: String = "[see docs](#section) and #real"
1279 .chars()
1280 .skip(l.start_char)
1281 .take(l.end_char - l.start_char)
1282 .collect();
1283 assert_eq!(span, "#real");
1284 }
1285
1286 #[test]
1287 fn parse_line_skips_label_inside_link_display_text() {
1288 let parsed = ParsedLine::parse("[#todo](notes/project.md)");
1289 let has_label = parsed
1290 .elements
1291 .iter()
1292 .any(|e| matches!(e.kind, ElementKind::Label));
1293 assert!(
1294 !has_label,
1295 "hashtag inside link display text should not become Label"
1296 );
1297 }
1298
1299 #[test]
1300 fn parse_line_skips_label_after_label_char() {
1301 let parsed = ParsedLine::parse("foo#bar baz");
1302 let has_label = parsed
1303 .elements
1304 .iter()
1305 .any(|e| matches!(e.kind, ElementKind::Label));
1306 assert!(
1307 !has_label,
1308 "word#tag should not emit Label without word boundary"
1309 );
1310 }
1311
1312 #[test]
1313 fn parse_line_skips_label_for_double_hash() {
1314 let parsed = ParsedLine::parse("##draft");
1318 let has_label = parsed
1319 .elements
1320 .iter()
1321 .any(|e| matches!(e.kind, ElementKind::Label));
1322 assert!(!has_label, "##draft should not emit Label");
1323 }
1324
1325 #[test]
1326 fn parse_line_skips_label_for_adjacent_hash_run() {
1327 let parsed = ParsedLine::parse("#tag#more");
1331 let labels: Vec<_> = parsed
1332 .elements
1333 .iter()
1334 .filter(|e| matches!(e.kind, ElementKind::Label))
1335 .collect();
1336 assert!(
1337 labels.is_empty(),
1338 "#tag#more should not emit Label, got {:?}",
1339 labels
1340 );
1341 }
1342
1343 #[test]
1344 fn parse_buffer_skips_label_inside_fenced_block() {
1345 let buffer = vec![
1346 "before".to_string(),
1347 "```".to_string(),
1348 "#inside".to_string(),
1349 "```".to_string(),
1350 "after #outside".to_string(),
1351 ];
1352 let lines = ParsedBuffer::parse(&buffer).lines;
1353 let inside_labels: Vec<_> = lines[2]
1354 .elements
1355 .iter()
1356 .filter(|e| matches!(e.kind, ElementKind::Label))
1357 .collect();
1358 assert!(
1359 inside_labels.is_empty(),
1360 "no Label emitted for hashtags in fenced blocks"
1361 );
1362
1363 let outside_labels: Vec<_> = lines[4]
1364 .elements
1365 .iter()
1366 .filter(|e| matches!(e.kind, ElementKind::Label))
1367 .collect();
1368 assert_eq!(outside_labels.len(), 1, "#outside still extracted");
1369 }
1370
1371 #[test]
1372 fn parse_range_full_equals_parse() {
1373 let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
1374 let full = ParsedBuffer::parse(&lines);
1375 let range_full = ParsedBuffer::parse_range(&lines, 0..lines.len());
1376 assert_eq!(full.lines.len(), range_full.lines.len());
1377 assert_eq!(full.kinds, range_full.kinds);
1378 for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
1379 assert_eq!(a.content_vis, b.content_vis);
1380 assert_eq!(a.elements.len(), b.elements.len());
1381 }
1382 }
1383
1384 #[test]
1385 fn parse_range_paragraph_only_slice() {
1386 let lines: Vec<String> = vec![
1387 "intro paragraph".into(),
1388 "".into(),
1389 "middle line".into(),
1390 "".into(),
1391 "outro".into(),
1392 ];
1393 let slice = ParsedBuffer::parse_range(&lines, 2..3);
1394 assert_eq!(slice.lines.len(), 1);
1395 assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
1396 }
1397
1398 #[test]
1399 fn splice_replaces_range() {
1400 let mut pb = ParsedBuffer::parse(&["alpha".into(), "beta".into(), "gamma".into()]);
1401 let replacement = ParsedBuffer::parse(&["BETA-NEW".into()]);
1402 let replacement_kind = replacement.kinds[0];
1403 pb.splice(1..2, replacement);
1404 assert_eq!(pb.lines.len(), 3);
1405 assert_eq!(pb.kinds.len(), 3);
1406 assert_eq!(
1407 pb.kinds[1], replacement_kind,
1408 "replacement landed at the wrong index"
1409 );
1410 }
1411
1412 #[cfg(debug_assertions)]
1413 #[test]
1414 #[should_panic(expected = "splice")]
1415 fn splice_panics_on_length_mismatch_in_debug() {
1416 let mut pb = ParsedBuffer::parse(&["a".into(), "b".into()]);
1417 let too_short = ParsedBuffer::parse(&["X".into()]);
1418 pb.splice(0..2, too_short);
1419 }
1420
1421 #[test]
1432 fn lazy_depth_blockquote_closes_at_first_blank() {
1433 let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
1434 let pb = ParsedBuffer::parse(&lines);
1435 assert_eq!(
1436 pb.lazy_depth,
1437 vec![1, 0, 0, 0],
1438 "blockquote closes at first blank per CommonMark §5.1; got {:?}",
1439 pb.lazy_depth,
1440 );
1441 }
1442
1443 #[test]
1449 fn lazy_depth_indented_code_across_blanks() {
1450 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1451 let pb = ParsedBuffer::parse(&lines);
1452 assert_eq!(
1453 pb.lazy_depth,
1454 vec![1, 1, 1],
1455 "indented code multi-chunk should keep lazy_depth > 0 across the blank \
1456 AND through the last content row; got {:?}",
1457 pb.lazy_depth,
1458 );
1459 }
1460
1461 #[test]
1465 fn lazy_depth_fenced_code_does_not_count() {
1466 let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
1467 let pb = ParsedBuffer::parse(&lines);
1468 assert_eq!(
1469 pb.lazy_depth,
1470 vec![0, 0, 0, 0],
1471 "fenced code is not lazy-continuable; got {:?}",
1472 pb.lazy_depth,
1473 );
1474 }
1475
1476 #[test]
1487 fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
1488 let lines: Vec<String> = vec!["> a".into(), "".into()];
1489 let pb = ParsedBuffer::parse(&lines);
1490 assert_eq!(
1491 pb.lazy_depth,
1492 vec![1, 0],
1493 "blockquote must close at the trailing blank; got {:?}",
1494 pb.lazy_depth,
1495 );
1496 assert!(
1497 pb.reset_boundaries.contains(&1),
1498 "the trailing blank at row 1 must be a reset boundary; got {:?}",
1499 pb.reset_boundaries,
1500 );
1501 }
1502
1503 #[test]
1514 fn boundaries_skip_rows_inside_lazy_block() {
1515 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1516 let pb = ParsedBuffer::parse(&lines);
1517 assert_eq!(
1518 pb.reset_boundaries,
1519 vec![0, lines.len()],
1520 "no boundary should land on a blank row inside the open indented-code block; \
1521 got {:?}",
1522 pb.reset_boundaries,
1523 );
1524 }
1525}