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