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
19const TAB_STOP: usize = 4;
21
22pub(super) fn tab_width_at(col: usize) -> usize {
24 TAB_STOP - (col % TAB_STOP)
25}
26
27pub(super) fn string_display_width(s: &str) -> usize {
30 s.graphemes(true).map(cluster_display_width).sum()
31}
32
33pub(super) fn blockquote_gutter(depth: u8) -> String {
39 let mut s = "│".repeat(depth as usize);
40 s.push(' ');
41 s
42}
43
44pub(super) fn blockquote_gutter_width(depth: u8) -> usize {
48 depth as usize + 1
49}
50
51pub(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
62pub(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
74pub(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#[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#[derive(Debug, Clone)]
127pub struct ParsedLine {
128 pub elements: Vec<Element>,
129 pub content_vis: Vec<bool>,
131 elem_vis: Vec<bool>,
134 elem_index: Vec<u16>,
137 modifier_mask: Vec<u8>,
143 list_sigil_end: Option<usize>,
146 pub image_placeholders: Vec<ImagePlaceholder>,
150 blockquote_depth: Option<u8>,
156}
157
158impl ParsedLine {
159 pub fn parse(line: &str) -> Self {
169 let owned = line.to_string();
170 if needs_synthetic_list_parent(line) {
171 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 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 pub fn in_any_element(&self, pos: usize) -> bool {
198 self.elem_vis.get(pos).copied().unwrap_or(false)
199 }
200
201 pub(super) fn modifiers_at(&self, pos: usize) -> u8 {
204 self.modifier_mask.get(pos).copied().unwrap_or(0)
205 }
206
207 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 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 pub fn list_sigil_end(&self) -> Option<usize> {
250 self.list_sigil_end
251 }
252
253 pub fn blockquote_depth(&self) -> Option<u8> {
255 self.blockquote_depth
256 }
257
258 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 #[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
300fn needs_synthetic_list_parent(line: &str) -> bool {
305 let trimmed = line.trim_start_matches([' ', '\t']);
306 if trimmed.len() == line.len() {
307 return false; }
309 list_marker_len(trimmed).is_some()
310}
311
312pub(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
325pub(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
361pub(super) const MOD_BOLD: u8 = 1 << 0;
363pub(super) const MOD_ITALIC: u8 = 1 << 1;
364pub(super) const MOD_STRIKE: u8 = 1 << 2;
365
366pub(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
376pub(super) fn mask_to_modifier(mask: u8) -> Modifier {
378 let mut m = Modifier::empty();
379 if mask & MOD_BOLD != 0 {
380 m |= Modifier::BOLD;
381 }
382 if mask & MOD_ITALIC != 0 {
383 m |= Modifier::ITALIC;
384 }
385 if mask & MOD_STRIKE != 0 {
386 m |= Modifier::CROSSED_OUT;
387 }
388 m
389}
390
391pub(super) fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
392 match kind {
393 None => {
394 if is_sigil_region {
395 Style::default().fg(theme.fg_muted.to_ratatui())
396 } else {
397 Style::default().fg(theme.fg.to_ratatui())
398 }
399 }
400 Some(ElementKind::Bold) => Style::default()
401 .fg(theme.accent.to_ratatui())
402 .add_modifier(Modifier::BOLD),
403 Some(ElementKind::Italic) => Style::default()
404 .fg(theme.fg_secondary.to_ratatui())
405 .add_modifier(Modifier::ITALIC),
406 Some(ElementKind::Strikethrough) => Style::default()
407 .fg(theme.fg_secondary.to_ratatui())
408 .add_modifier(Modifier::CROSSED_OUT),
409 Some(ElementKind::InlineCode) => Style::default()
410 .fg(theme.fg.to_ratatui())
411 .bg(theme.bg_selected.to_ratatui()),
412 Some(ElementKind::Link) => Style::default()
413 .fg(theme.accent.to_ratatui())
414 .add_modifier(Modifier::UNDERLINED),
415 Some(ElementKind::Image) => Style::default()
416 .fg(theme.accent.to_ratatui())
417 .add_modifier(Modifier::ITALIC),
418 Some(ElementKind::HeadingH1) => {
419 if is_sigil_region {
420 Style::default().fg(theme.fg_muted.to_ratatui())
421 } else {
422 Style::default()
423 .fg(theme.accent.to_ratatui())
424 .add_modifier(Modifier::BOLD)
425 }
426 }
427 Some(ElementKind::HeadingH2) => {
428 if is_sigil_region {
429 Style::default().fg(theme.fg_muted.to_ratatui())
430 } else {
431 Style::default()
432 .fg(theme.fg.to_ratatui())
433 .add_modifier(Modifier::BOLD)
434 }
435 }
436 Some(ElementKind::HeadingH3) => {
437 if is_sigil_region {
438 Style::default().fg(theme.fg_muted.to_ratatui())
439 } else {
440 Style::default().fg(theme.fg_secondary.to_ratatui())
441 }
442 }
443 Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
444 Some(ElementKind::WikiLink) => Style::default()
445 .fg(theme.color_directory.to_ratatui())
446 .add_modifier(Modifier::UNDERLINED),
447 Some(ElementKind::Label) => Style::default()
448 .fg(theme.color_tag.to_ratatui())
449 .add_modifier(Modifier::BOLD),
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::super::parse_incremental::LineConstructKind;
456 use super::*;
457 use ratatui::style::Modifier;
458 fn t() -> Theme {
459 Theme::default()
460 }
461 fn text(spans: &[Span]) -> String {
462 spans.iter().map(|s| s.content.as_ref()).collect()
463 }
464
465 #[test]
466 fn blockquote_lazy_continuation_carries_depth() {
467 let buf = ParsedBuffer::parse(&["> first".to_string(), "second".to_string()]);
472 assert_eq!(buf.lines[0].blockquote_depth(), Some(1));
473 assert_eq!(
474 buf.lines[1].blockquote_depth(),
475 Some(1),
476 "lazy continuation line must carry the blockquote depth"
477 );
478
479 let nested = ParsedBuffer::parse(&[">> a".to_string(), "b".to_string()]);
481 assert_eq!(nested.lines[0].blockquote_depth(), Some(2));
482 assert_eq!(nested.lines[1].blockquote_depth(), Some(2));
483
484 let ended =
486 ParsedBuffer::parse(&["> first".to_string(), String::new(), "plain".to_string()]);
487 assert_eq!(ended.lines[0].blockquote_depth(), Some(1));
488 assert_eq!(ended.lines[2].blockquote_depth(), None);
489 }
490
491 #[test]
492 fn indented_code_excludes_trailing_blank_keeps_interior() {
493 use super::super::parse_incremental::LineConstructKind::{Blank, IndentedCode, Plain};
494 let kinds = |lines: &[&str]| {
495 let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
496 ParsedBuffer::parse(&owned).kinds
497 };
498 assert_eq!(
500 kinds(&[" code", "", "outro"]),
501 vec![IndentedCode, Blank, Plain]
502 );
503 assert_eq!(
505 kinds(&[" a", "", " b"]),
506 vec![IndentedCode, IndentedCode, IndentedCode]
507 );
508 assert_eq!(
510 kinds(&[" a", "", "", "outro"]),
511 vec![IndentedCode, Blank, Blank, Plain]
512 );
513 assert_eq!(
516 kinds(&[" Line 1", " Line 2", "Line 3"]),
517 vec![IndentedCode, IndentedCode, Plain]
518 );
519 assert_eq!(
521 kinds(&[" line 1", " line 2", "", "", " line 3"]),
522 vec![
523 IndentedCode,
524 IndentedCode,
525 IndentedCode,
526 IndentedCode,
527 IndentedCode
528 ]
529 );
530 }
531
532 #[test]
533 fn gutter_width_matches_rendered() {
534 for d in 1u8..=4 {
537 assert_eq!(
538 blockquote_gutter_width(d),
539 string_display_width(&blockquote_gutter(d)),
540 "gutter width/string disagree at depth {d}"
541 );
542 }
543 }
544
545 #[test]
546 fn blockquote_depth_and_sigil_end() {
547 let p = ParsedLine::parse("> hello");
548 assert_eq!(p.blockquote_depth(), Some(1));
549 assert_eq!(p.blockquote_sigil_end(), Some(2));
551
552 let p2 = ParsedLine::parse(">> deep");
553 assert_eq!(p2.blockquote_depth(), Some(2));
554
555 let plain = ParsedLine::parse("not a quote");
556 assert_eq!(plain.blockquote_depth(), None);
557 assert_eq!(plain.blockquote_sigil_end(), None);
558 }
559 #[test]
560 fn parse_bold_range() {
561 let e = MarkdownSpanner::parse_elements("**bold**");
562 let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
563 assert_eq!((b.start_char, b.end_char), (0, 8));
564 }
565 #[test]
566 fn parse_italic() {
567 assert!(
568 MarkdownSpanner::parse_elements("*hi*")
569 .iter()
570 .any(|e| e.kind == ElementKind::Italic)
571 );
572 }
573 #[test]
574 fn parse_strikethrough() {
575 let e = MarkdownSpanner::parse_elements("~~gone~~");
576 let s = e
577 .iter()
578 .find(|x| x.kind == ElementKind::Strikethrough)
579 .unwrap();
580 assert_eq!((s.start_char, s.end_char), (0, 8));
581 }
582 #[test]
583 fn strikethrough_renders_with_crossed_out_modifier() {
584 let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
585 assert_eq!(text(&s), "gone");
586 assert!(
587 s.iter()
588 .any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
589 );
590 }
591 #[test]
592 fn parse_inline_code() {
593 assert!(
594 MarkdownSpanner::parse_elements("`x`")
595 .iter()
596 .any(|e| e.kind == ElementKind::InlineCode)
597 );
598 }
599 #[test]
600 fn parse_link() {
601 assert!(
602 MarkdownSpanner::parse_elements("[t](u)")
603 .iter()
604 .any(|e| e.kind == ElementKind::Link)
605 );
606 }
607
608 #[test]
609 fn parse_image_emits_image_element_and_placeholder() {
610 let line = "see  here";
611 let parsed = ParsedLine::parse(line);
612 let img = parsed
613 .elements
614 .iter()
615 .find(|e| e.kind == ElementKind::Image)
616 .expect("image element");
617 assert_eq!(line.chars().nth(img.start_char), Some('!'));
618 assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
619 let ph = parsed
620 .image_placeholders
621 .iter()
622 .find(|p| p.start_char == img.start_char)
623 .expect("placeholder for image");
624 assert_eq!(ph.placeholder, "[img.png]");
625 for pos in img.start_char..img.end_char {
626 assert!(
627 !parsed.content_vis[pos],
628 "char {pos} should be hidden inside image span"
629 );
630 }
631 }
632
633 #[test]
634 fn render_image_substitutes_placeholder_text() {
635 let line = "before  after";
636 let parsed = ParsedLine::parse(line);
637 let spans =
638 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
639 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
640 assert!(
641 rendered.contains("[pic.gif]"),
642 "rendered text {rendered:?} should include placeholder"
643 );
644 assert!(
645 !rendered.contains("![alt]"),
646 "raw image syntax should not appear in rendered output: {rendered:?}"
647 );
648 }
649
650 #[test]
651 fn render_image_with_empty_alt_uses_filename() {
652 let line = "";
653 let parsed = ParsedLine::parse(line);
654 let spans =
655 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
656 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
657 assert_eq!(rendered, "[image.png]");
658 }
659
660 #[test]
661 fn rendered_cursor_col_accounts_for_placeholder_width() {
662 let line = "a  b";
664 let parsed = ParsedLine::parse(line);
665 let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
666 line,
667 &parsed,
668 0,
669 "a  b".chars().count(), true,
671 false,
672 );
673 assert_eq!(after_placeholder, 11);
675 }
676 #[test]
677 fn parse_h1() {
678 assert!(
679 MarkdownSpanner::parse_elements("# T")
680 .iter()
681 .any(|e| e.kind == ElementKind::HeadingH1)
682 );
683 }
684 #[test]
685 fn parse_h2() {
686 assert!(
687 MarkdownSpanner::parse_elements("## T")
688 .iter()
689 .any(|e| e.kind == ElementKind::HeadingH2)
690 );
691 }
692 #[test]
693 fn parse_h3() {
694 assert!(
695 MarkdownSpanner::parse_elements("### T")
696 .iter()
697 .any(|e| e.kind == ElementKind::HeadingH3)
698 );
699 }
700 #[test]
701 fn force_raw_no_styling() {
702 let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
703 assert_eq!(text(&s), "**x**");
704 assert!(
705 !s.iter()
706 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
707 );
708 }
709 #[test]
710 fn plain_text_passthrough() {
711 let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
712 assert_eq!(text(&s), "hi");
713 }
714 #[test]
715 fn bold_without_cursor_hides_markers() {
716 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
717 assert_eq!(text(&s), "bold");
718 assert!(
719 s.iter()
720 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
721 );
722 }
723 #[test]
724 fn bold_cursor_inside_shows_raw() {
725 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
726 assert_eq!(text(&s), "**bold**");
727 }
728 #[test]
729 fn bold_cursor_outside_stays_rendered() {
730 let line = "hello **bold** world";
731 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
732 assert!(!text(&s).contains("**"));
733 }
734 #[test]
735 fn italic_cursor_inside_shows_raw() {
736 let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
737 assert_eq!(text(&s), "*hi*");
738 }
739 #[test]
740 fn inline_code_hides_backticks() {
741 let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
742 assert_eq!(text(&s), "x");
743 }
744 #[test]
745 fn h1_first_line_contains_hash() {
746 let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
747 assert!(text(&s).contains('#'));
748 assert!(text(&s).contains('T'));
749 }
750 #[test]
751 fn continuation_line_no_hash() {
752 let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
753 assert!(!text(&s).contains('#'));
754 }
755 #[test]
756 fn unordered_list_shows_marker() {
757 let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
758 assert!(
759 text(&s).starts_with("- "),
760 "expected '- item', got '{}'",
761 text(&s)
762 );
763 assert!(text(&s).contains("item"));
764 }
765 #[test]
766 fn ordered_list_shows_marker() {
767 let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
768 assert!(
769 text(&s).starts_with("1. "),
770 "expected '1. item', got '{}'",
771 text(&s)
772 );
773 }
774 #[test]
775 fn nested_list_4space_link_rendered() {
776 let line = " - [my link](url)";
778 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
779 assert!(
782 s.iter()
783 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
784 "link text should be underlined on a 4-space-indented nested list item"
785 );
786 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
787 assert!(
788 rendered.contains("my link"),
789 "link display text should be visible; got {:?}",
790 rendered
791 );
792 assert!(
793 !rendered.contains("](url)"),
794 "link URL sigil should be hidden; got {:?}",
795 rendered
796 );
797 }
798
799 #[test]
800 fn nested_list_tab_bold_rendered() {
801 let line = "\t- **bold nested**";
802 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
803 assert!(
804 s.iter()
805 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
806 "bold text should be styled on a tab-indented nested list item"
807 );
808 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
809 assert!(
810 !rendered.contains("**"),
811 "bold markers should be hidden; got {:?}",
812 rendered
813 );
814 }
815
816 #[test]
817 fn nested_list_4space_wikilink_rendered() {
818 let line = " - [[Target Note]]";
819 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
820 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
821 assert!(
822 !rendered.contains("[["),
823 "wikilink brackets should be hidden; got {:?}",
824 rendered
825 );
826 assert!(
827 rendered.contains("Target Note"),
828 "wikilink target text should render; got {:?}",
829 rendered
830 );
831 }
832
833 #[test]
834 fn nested_list_2space_still_renders_link() {
835 let line = " - [link](url)";
837 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
838 assert!(
839 s.iter()
840 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
841 );
842 }
843
844 #[test]
845 fn empty_heading_shows_hash_sigil() {
846 let line = "# ";
847 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
848 assert!(
849 text(&s).contains('#'),
850 "hash sigil should render in empty heading"
851 );
852 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
853 assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
854 }
855 #[test]
856 fn empty_heading_hash_only_shows() {
857 let line = "#";
858 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
859 assert!(text(&s).contains('#'));
860 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
861 assert_eq!(col, 1);
862 }
863 #[test]
864 fn heading_trailing_spaces_are_rendered() {
865 let line = "# Hello ";
866 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
867 assert_eq!(
868 text(&s),
869 "# Hello ",
870 "trailing spaces in heading should render"
871 );
872 }
873 #[test]
874 fn heading_trailing_spaces_cursor_col_correct() {
875 let line = "# Hello ";
876 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
878 assert_eq!(
879 col, 9,
880 "cursor in trailing space of heading should map to rendered col 9"
881 );
882 }
883 #[test]
884 fn trailing_spaces_are_rendered() {
885 let line = "hello ";
886 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
887 assert_eq!(text(&s), "hello ");
888 }
889 #[test]
890 fn trailing_spaces_cursor_col_correct() {
891 let line = "hello ";
892 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
893 assert_eq!(col, 7);
894 }
895 #[test]
896 fn list_marker_on_continuation_line_hidden() {
897 let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
898 assert!(!text(&s).starts_with("- "));
899 }
900 #[test]
901 fn parsed_line_heading_sigil_end_empty_heading() {
902 let p = ParsedLine::parse("#");
904 assert_eq!(p.heading_sigil_end(), Some(1));
905 }
906 #[test]
907 fn parsed_line_heading_sigil_end_with_content() {
908 let p = ParsedLine::parse("# T");
910 assert_eq!(p.heading_sigil_end(), Some(2));
911 }
912 #[test]
913 fn parsed_line_reuse_matches_individual() {
914 let line = "**hello** world";
915 let parsed = ParsedLine::parse(line);
916 let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
917 let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
918 assert_eq!(
919 s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
920 s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
921 );
922 }
923
924 #[test]
927 fn parse_wikilink() {
928 let e = MarkdownSpanner::parse_elements("[[My Note]]");
929 let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
930 assert_eq!((wl.start_char, wl.end_char), (0, 11));
931 }
932
933 #[test]
934 fn wikilink_without_cursor_hides_brackets() {
935 let line = "[[My Note]]";
936 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
937 assert_eq!(text(&s), "My Note");
938 assert!(
939 s.iter()
940 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
941 );
942 }
943
944 #[test]
945 fn wikilink_cursor_inside_shows_brackets() {
946 let line = "[[My Note]]";
947 let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
949 assert_eq!(text(&s), "[[My Note]]");
950 }
951
952 #[test]
953 fn wikilink_cursor_outside_hides_brackets() {
954 let line = "hello [[My Note]] world";
955 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
956 assert!(!text(&s).contains("[["));
957 assert!(!text(&s).contains("]]"));
958 }
959
960 #[test]
961 fn wikilink_in_heading_rendered() {
962 let line = "# See [[Topic]]";
963 let e = MarkdownSpanner::parse_elements(line);
964 assert!(
965 e.iter().any(|x| x.kind == ElementKind::WikiLink),
966 "wikilink inside heading should produce a WikiLink element"
967 );
968 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
969 assert_eq!(text(&s), "# See Topic", "wikilink brackets hidden, # kept");
970 }
971
972 #[test]
973 fn heading_with_link_does_not_leak_bracket() {
974 let line = "# [text](http://x)";
975 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
976 assert_eq!(text(&s), "# text");
979 }
980
981 #[test]
982 fn heading_with_bold_does_not_leak_asterisk() {
983 let line = "# **bold**";
984 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
985 assert_eq!(
986 text(&s),
987 "# bold",
988 "leading * must not leak into heading sigil"
989 );
990 }
991
992 #[test]
993 fn bold_wikilink_is_bold() {
994 let line = "**[[Topic]]**";
995 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
996 assert_eq!(text(&s), "Topic");
997 assert!(
998 s.iter()
999 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)
1000 && sp.content.contains("Topic")),
1001 "wikilink wrapped in ** must render bold"
1002 );
1003 }
1004
1005 #[test]
1006 fn italic_link_is_italic() {
1007 let line = "*[text](http://x)*";
1008 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1009 assert_eq!(text(&s), "text");
1010 assert!(
1011 s.iter()
1012 .any(|sp| sp.style.add_modifier.contains(Modifier::ITALIC)
1013 && sp.content.contains("text")),
1014 "link wrapped in * must render italic"
1015 );
1016 }
1017
1018 #[test]
1019 fn bold_italic_wikilink_is_bold_and_italic() {
1020 let line = "***[[Topic]]***";
1021 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1022 assert_eq!(text(&s), "Topic");
1023 assert!(
1024 s.iter().any(|sp| {
1025 sp.content.contains("Topic")
1026 && sp.style.add_modifier.contains(Modifier::BOLD)
1027 && sp.style.add_modifier.contains(Modifier::ITALIC)
1028 }),
1029 "wikilink in *** *** must render both bold and italic"
1030 );
1031 }
1032
1033 #[test]
1034 fn bold_italic_plain_text() {
1035 let line = "***text***";
1036 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1037 assert_eq!(text(&s), "text");
1038 assert!(
1039 s.iter().any(|sp| {
1040 sp.content.contains("text")
1041 && sp.style.add_modifier.contains(Modifier::BOLD)
1042 && sp.style.add_modifier.contains(Modifier::ITALIC)
1043 }),
1044 "*** *** must render both bold and italic"
1045 );
1046 }
1047
1048 #[test]
1049 fn wikilink_mid_sentence() {
1050 let line = "See [[Topic]] for details";
1051 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1052 assert_eq!(text(&s), "See Topic for details");
1053 }
1054
1055 #[test]
1056 fn wikilink_cursor_col_accounts_for_brackets() {
1057 let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
1060 assert_eq!(col, 2);
1061
1062 let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
1066 assert_eq!(col2, 0);
1067 }
1068
1069 #[test]
1070 fn buffer_parse_nested_list_under_parent() {
1071 let lines = vec".to_string(),
1075 ];
1076 let parsed = ParsedBuffer::parse(&lines).lines;
1077 assert_eq!(parsed.len(), 2);
1078
1079 assert_eq!(parsed[0].list_sigil_end(), Some(2));
1081
1082 assert_eq!(
1084 parsed[1].list_sigil_end(),
1085 Some(6),
1086 "child's sigil_end should be after ' - ' (6 chars)"
1087 );
1088
1089 assert!(
1091 parsed[1]
1092 .elements
1093 .iter()
1094 .any(|e| e.kind == ElementKind::Link),
1095 "nested list item should contain a Link element"
1096 );
1097 }
1098
1099 #[test]
1100 fn buffer_parse_standalone_2space_list_still_works() {
1101 let lines = vec".to_string()];
1103 let parsed = ParsedBuffer::parse(&lines).lines;
1104 assert!(
1105 parsed[0]
1106 .elements
1107 .iter()
1108 .any(|e| e.kind == ElementKind::Link)
1109 );
1110 assert_eq!(parsed[0].list_sigil_end(), Some(4));
1111 }
1112
1113 #[test]
1114 fn buffer_parse_top_level_unchanged() {
1115 let lines = vec".to_string()];
1117 let parsed = ParsedBuffer::parse(&lines).lines;
1118 assert!(
1119 parsed[0]
1120 .elements
1121 .iter()
1122 .any(|e| e.kind == ElementKind::Link)
1123 );
1124 assert_eq!(parsed[0].list_sigil_end(), Some(2));
1125 }
1126
1127 #[test]
1128 fn buffer_parse_empty_lines_preserved() {
1129 let lines = vec![
1130 "# Title".to_string(),
1131 String::new(),
1132 "paragraph".to_string(),
1133 ];
1134 let parsed = ParsedBuffer::parse(&lines).lines;
1135 assert_eq!(parsed.len(), 3);
1136 assert_eq!(parsed[1].elements.len(), 0);
1137 assert_eq!(parsed[1].content_vis.len(), 0);
1138 }
1139
1140 #[test]
1141 fn buffer_parse_ordered_nested_list() {
1142 let lines = vec!["1. first".to_string(), " 1. nested".to_string()];
1143 let parsed = ParsedBuffer::parse(&lines).lines;
1144 assert_eq!(parsed[0].list_sigil_end(), Some(3));
1145 assert_eq!(parsed[1].list_sigil_end(), Some(7));
1146 }
1147
1148 #[test]
1149 fn buffer_parse_setext_h1_spans_two_rows() {
1150 let lines = vec!["My Heading".to_string(), "==========".to_string()];
1156 let parsed = ParsedBuffer::parse(&lines).lines;
1157 assert!(
1158 parsed[0]
1159 .elements
1160 .iter()
1161 .any(|e| e.kind == ElementKind::HeadingH1),
1162 "setext underline must tag row 0 as HeadingH1"
1163 );
1164 assert!(
1165 parsed[1]
1166 .elements
1167 .iter()
1168 .any(|e| e.kind == ElementKind::HeadingH1),
1169 "setext underline must tag row 1 as HeadingH1"
1170 );
1171 assert!(
1173 parsed[1].content_vis.iter().all(|v| !v),
1174 "setext underline row has no content"
1175 );
1176 }
1177
1178 #[test]
1179 fn buffer_parse_multiline_blockquote() {
1180 let lines = vec!["> first line".to_string(), "> second line".to_string()];
1183 let parsed = ParsedBuffer::parse(&lines).lines;
1184 assert!(
1185 parsed[0]
1186 .elements
1187 .iter()
1188 .any(|e| e.kind == ElementKind::Blockquote),
1189 "row 0 must tag as Blockquote"
1190 );
1191 assert!(
1192 parsed[1]
1193 .elements
1194 .iter()
1195 .any(|e| e.kind == ElementKind::Blockquote),
1196 "row 1 must tag as Blockquote"
1197 );
1198 }
1199
1200 #[test]
1201 fn parse_line_emits_label_for_hashtag() {
1202 let line = "see #rust later";
1203 let parsed = ParsedLine::parse(line);
1204 let label = parsed
1205 .elements
1206 .iter()
1207 .find(|e| matches!(e.kind, ElementKind::Label));
1208 assert!(
1209 label.is_some(),
1210 "expected Label element: {:?}",
1211 parsed.elements
1212 );
1213 let l = label.unwrap();
1214 let span: String = line
1215 .chars()
1216 .skip(l.start_char)
1217 .take(l.end_char - l.start_char)
1218 .collect();
1219 assert_eq!(span, "#rust");
1220 }
1221
1222 #[test]
1223 fn parse_line_skips_label_inside_inline_code() {
1224 let parsed = ParsedLine::parse("use `#foo` here");
1225 let has_label = parsed
1226 .elements
1227 .iter()
1228 .any(|e| matches!(e.kind, ElementKind::Label));
1229 assert!(!has_label, "should not emit Label inside inline code");
1230 }
1231
1232 #[test]
1235 fn parse_line_skips_label_inside_markdown_link() {
1236 let parsed = ParsedLine::parse("[see docs](#section) and #real");
1237 let labels: Vec<_> = parsed
1238 .elements
1239 .iter()
1240 .filter(|e| matches!(e.kind, ElementKind::Label))
1241 .collect();
1242 assert_eq!(
1243 labels.len(),
1244 1,
1245 "only #real should be a label, not #section in the link"
1246 );
1247 let l = labels[0];
1248 let span: String = "[see docs](#section) and #real"
1249 .chars()
1250 .skip(l.start_char)
1251 .take(l.end_char - l.start_char)
1252 .collect();
1253 assert_eq!(span, "#real");
1254 }
1255
1256 #[test]
1257 fn parse_line_skips_label_inside_link_display_text() {
1258 let parsed = ParsedLine::parse("[#todo](notes/project.md)");
1259 let has_label = parsed
1260 .elements
1261 .iter()
1262 .any(|e| matches!(e.kind, ElementKind::Label));
1263 assert!(
1264 !has_label,
1265 "hashtag inside link display text should not become Label"
1266 );
1267 }
1268
1269 #[test]
1270 fn parse_line_skips_label_after_label_char() {
1271 let parsed = ParsedLine::parse("foo#bar baz");
1272 let has_label = parsed
1273 .elements
1274 .iter()
1275 .any(|e| matches!(e.kind, ElementKind::Label));
1276 assert!(
1277 !has_label,
1278 "word#tag should not emit Label without word boundary"
1279 );
1280 }
1281
1282 #[test]
1283 fn parse_line_skips_label_for_double_hash() {
1284 let parsed = ParsedLine::parse("##draft");
1288 let has_label = parsed
1289 .elements
1290 .iter()
1291 .any(|e| matches!(e.kind, ElementKind::Label));
1292 assert!(!has_label, "##draft should not emit Label");
1293 }
1294
1295 #[test]
1296 fn parse_line_skips_label_for_adjacent_hash_run() {
1297 let parsed = ParsedLine::parse("#tag#more");
1301 let labels: Vec<_> = parsed
1302 .elements
1303 .iter()
1304 .filter(|e| matches!(e.kind, ElementKind::Label))
1305 .collect();
1306 assert!(
1307 labels.is_empty(),
1308 "#tag#more should not emit Label, got {:?}",
1309 labels
1310 );
1311 }
1312
1313 #[test]
1314 fn parse_buffer_skips_label_inside_fenced_block() {
1315 let buffer = vec![
1316 "before".to_string(),
1317 "```".to_string(),
1318 "#inside".to_string(),
1319 "```".to_string(),
1320 "after #outside".to_string(),
1321 ];
1322 let lines = ParsedBuffer::parse(&buffer).lines;
1323 let inside_labels: Vec<_> = lines[2]
1324 .elements
1325 .iter()
1326 .filter(|e| matches!(e.kind, ElementKind::Label))
1327 .collect();
1328 assert!(
1329 inside_labels.is_empty(),
1330 "no Label emitted for hashtags in fenced blocks"
1331 );
1332
1333 let outside_labels: Vec<_> = lines[4]
1334 .elements
1335 .iter()
1336 .filter(|e| matches!(e.kind, ElementKind::Label))
1337 .collect();
1338 assert_eq!(outside_labels.len(), 1, "#outside still extracted");
1339 }
1340
1341 #[test]
1342 fn parse_range_full_equals_parse() {
1343 let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
1344 let full = ParsedBuffer::parse(&lines);
1345 let range_full = ParsedBuffer::parse_range(&lines, 0..lines.len());
1346 assert_eq!(full.lines.len(), range_full.lines.len());
1347 assert_eq!(full.kinds, range_full.kinds);
1348 for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
1349 assert_eq!(a.content_vis, b.content_vis);
1350 assert_eq!(a.elements.len(), b.elements.len());
1351 }
1352 }
1353
1354 #[test]
1355 fn parse_range_paragraph_only_slice() {
1356 let lines: Vec<String> = vec![
1357 "intro paragraph".into(),
1358 "".into(),
1359 "middle line".into(),
1360 "".into(),
1361 "outro".into(),
1362 ];
1363 let slice = ParsedBuffer::parse_range(&lines, 2..3);
1364 assert_eq!(slice.lines.len(), 1);
1365 assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
1366 }
1367
1368 #[test]
1369 fn splice_replaces_range() {
1370 let mut pb = ParsedBuffer::parse(&["alpha".into(), "beta".into(), "gamma".into()]);
1371 let replacement = ParsedBuffer::parse(&["BETA-NEW".into()]);
1372 let replacement_kind = replacement.kinds[0];
1373 pb.splice(1..2, replacement);
1374 assert_eq!(pb.lines.len(), 3);
1375 assert_eq!(pb.kinds.len(), 3);
1376 assert_eq!(
1377 pb.kinds[1], replacement_kind,
1378 "replacement landed at the wrong index"
1379 );
1380 }
1381
1382 #[cfg(debug_assertions)]
1383 #[test]
1384 #[should_panic(expected = "splice")]
1385 fn splice_panics_on_length_mismatch_in_debug() {
1386 let mut pb = ParsedBuffer::parse(&["a".into(), "b".into()]);
1387 let too_short = ParsedBuffer::parse(&["X".into()]);
1388 pb.splice(0..2, too_short);
1389 }
1390
1391 #[test]
1402 fn lazy_depth_blockquote_closes_at_first_blank() {
1403 let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
1404 let pb = ParsedBuffer::parse(&lines);
1405 assert_eq!(
1406 pb.lazy_depth,
1407 vec![1, 0, 0, 0],
1408 "blockquote closes at first blank per CommonMark §5.1; got {:?}",
1409 pb.lazy_depth,
1410 );
1411 }
1412
1413 #[test]
1419 fn lazy_depth_indented_code_across_blanks() {
1420 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1421 let pb = ParsedBuffer::parse(&lines);
1422 assert_eq!(
1423 pb.lazy_depth,
1424 vec![1, 1, 1],
1425 "indented code multi-chunk should keep lazy_depth > 0 across the blank \
1426 AND through the last content row; got {:?}",
1427 pb.lazy_depth,
1428 );
1429 }
1430
1431 #[test]
1435 fn lazy_depth_fenced_code_does_not_count() {
1436 let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
1437 let pb = ParsedBuffer::parse(&lines);
1438 assert_eq!(
1439 pb.lazy_depth,
1440 vec![0, 0, 0, 0],
1441 "fenced code is not lazy-continuable; got {:?}",
1442 pb.lazy_depth,
1443 );
1444 }
1445
1446 #[test]
1457 fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
1458 let lines: Vec<String> = vec!["> a".into(), "".into()];
1459 let pb = ParsedBuffer::parse(&lines);
1460 assert_eq!(
1461 pb.lazy_depth,
1462 vec![1, 0],
1463 "blockquote must close at the trailing blank; got {:?}",
1464 pb.lazy_depth,
1465 );
1466 assert!(
1467 pb.reset_boundaries.contains(&1),
1468 "the trailing blank at row 1 must be a reset boundary; got {:?}",
1469 pb.reset_boundaries,
1470 );
1471 }
1472
1473 #[test]
1484 fn boundaries_skip_rows_inside_lazy_block() {
1485 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1486 let pb = ParsedBuffer::parse(&lines);
1487 assert_eq!(
1488 pb.reset_boundaries,
1489 vec![0, lines.len()],
1490 "no boundary should land on a blank row inside the open indented-code block; \
1491 got {:?}",
1492 pb.reset_boundaries,
1493 );
1494 }
1495}