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 {
83 cluster
84 .chars()
85 .next()
86 .and_then(unicode_width::UnicodeWidthChar::width)
87 .unwrap_or(1)
88}
89
90#[derive(Debug, Clone, PartialEq)]
91pub struct Element {
92 pub start_char: usize,
93 pub end_char: usize,
94 pub kind: ElementKind,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum ElementKind {
99 Bold,
100 Italic,
101 Strikethrough,
102 InlineCode,
103 Link,
104 HeadingH1,
105 HeadingH2,
106 HeadingH3,
107 Blockquote,
108 WikiLink,
109 Image,
110 Label,
111}
112
113#[derive(Debug, Clone)]
118pub struct ImagePlaceholder {
119 pub start_char: usize,
120 pub end_char: usize,
121 pub placeholder: String,
122 pub placeholder_width: usize,
123}
124
125#[derive(Debug, Clone)]
129pub struct ParsedLine {
130 pub elements: Vec<Element>,
131 pub content_vis: Vec<bool>,
133 elem_vis: Vec<bool>,
136 elem_index: Vec<u16>,
139 modifier_mask: Vec<u8>,
145 list_sigil_end: Option<usize>,
148 pub image_placeholders: Vec<ImagePlaceholder>,
152 blockquote_depth: Option<u8>,
158}
159
160impl ParsedLine {
161 pub fn parse(line: &str) -> Self {
171 let owned = line.to_string();
172 if needs_synthetic_list_parent(line) {
173 ParsedBuffer::parse(&["- ".to_string(), owned])
176 .lines
177 .pop()
178 .expect("ParsedBuffer::parse returns one row per input line")
179 } else {
180 ParsedBuffer::parse(std::slice::from_ref(&owned))
181 .lines
182 .pop()
183 .expect("ParsedBuffer::parse always returns at least one ParsedLine")
184 }
185 }
186
187 pub fn elem_at(&self, pos: usize) -> Option<usize> {
189 self.elem_index.get(pos).and_then(|&tag| {
190 if tag == 0 {
191 None
192 } else {
193 Some((tag as usize) - 1)
194 }
195 })
196 }
197
198 pub fn in_any_element(&self, pos: usize) -> bool {
200 self.elem_vis.get(pos).copied().unwrap_or(false)
201 }
202
203 pub(super) fn modifiers_at(&self, pos: usize) -> u8 {
206 self.modifier_mask.get(pos).copied().unwrap_or(0)
207 }
208
209 pub fn heading_sigil_end(&self) -> Option<usize> {
216 self.elements
217 .iter()
218 .position(|e| {
219 matches!(
220 e.kind,
221 ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
222 )
223 })
224 .map(|idx| self.first_content_char(idx))
225 }
226
227 fn first_content_char(&self, elem_idx: usize) -> usize {
237 let e = &self.elements[elem_idx];
238 for i in e.start_char..e.end_char {
239 if i < self.content_vis.len() && self.content_vis[i] {
240 return i;
241 }
242 if self.elem_at(i).is_some_and(|inner| inner != elem_idx) {
243 return i;
244 }
245 }
246 e.end_char
247 }
248
249 pub fn list_sigil_end(&self) -> Option<usize> {
252 self.list_sigil_end
253 }
254
255 pub fn blockquote_depth(&self) -> Option<u8> {
257 self.blockquote_depth
258 }
259
260 pub fn blockquote_sigil_end(&self) -> Option<usize> {
265 self.elements
266 .iter()
267 .position(|e| e.kind == ElementKind::Blockquote)
268 .map(|idx| self.first_content_char(idx))
269 }
270
271 #[cfg(debug_assertions)]
276 pub(super) fn debug_assert_eq_to(&self, other: &Self, row: usize) {
277 assert_eq!(
278 self.content_vis, other.content_vis,
279 "row {row} content_vis diverge"
280 );
281 assert_eq!(self.elem_vis, other.elem_vis, "row {row} elem_vis diverge");
282 assert_eq!(
283 self.elem_index, other.elem_index,
284 "row {row} elem_index diverge"
285 );
286 assert_eq!(
287 self.list_sigil_end, other.list_sigil_end,
288 "row {row} list_sigil_end diverge"
289 );
290 assert_eq!(
291 self.blockquote_depth, other.blockquote_depth,
292 "row {row} blockquote_depth diverge"
293 );
294 assert_eq!(
295 self.elements.len(),
296 other.elements.len(),
297 "row {row} elements.len() diverge"
298 );
299 }
300}
301
302fn needs_synthetic_list_parent(line: &str) -> bool {
307 let trimmed = line.trim_start_matches([' ', '\t']);
308 if trimmed.len() == line.len() {
309 return false; }
311 list_marker_len(trimmed).is_some()
312}
313
314pub(super) fn leading_ws_byte_len(line: &str) -> usize {
322 line.bytes()
323 .take_while(|b| *b == b' ' || *b == b'\t')
324 .count()
325}
326
327pub(super) fn tag_to_kind(tag: &Tag) -> Option<ElementKind> {
332 Some(match tag {
333 Tag::Strong => ElementKind::Bold,
334 Tag::Emphasis => ElementKind::Italic,
335 Tag::Strikethrough => ElementKind::Strikethrough,
336 Tag::Link { .. } => ElementKind::Link,
337 Tag::BlockQuote(_) => ElementKind::Blockquote,
338 Tag::Heading { level, .. } => match level {
339 HeadingLevel::H1 => ElementKind::HeadingH1,
340 HeadingLevel::H2 => ElementKind::HeadingH2,
341 _ => ElementKind::HeadingH3,
342 },
343 _ => return None,
344 })
345}
346
347pub(super) fn list_marker_len(s: &str) -> Option<usize> {
348 if s.starts_with("- ") || s.starts_with("* ") || s.starts_with("+ ") {
349 return Some(2);
350 }
351 let bytes = s.as_bytes();
352 let mut i = 0;
353 while i < bytes.len() && bytes[i].is_ascii_digit() {
354 i += 1;
355 }
356 if i > 0 && i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b' ' {
357 Some(i + 2)
358 } else {
359 None
360 }
361}
362
363pub(super) const MOD_BOLD: u8 = 1 << 0;
365pub(super) const MOD_ITALIC: u8 = 1 << 1;
366pub(super) const MOD_STRIKE: u8 = 1 << 2;
367
368pub(super) fn modifier_bit(kind: ElementKind) -> u8 {
370 match kind {
371 ElementKind::Bold => MOD_BOLD,
372 ElementKind::Italic => MOD_ITALIC,
373 ElementKind::Strikethrough => MOD_STRIKE,
374 _ => 0,
375 }
376}
377
378pub(super) fn mask_to_modifier(mask: u8) -> Modifier {
380 let mut m = Modifier::empty();
381 if mask & MOD_BOLD != 0 {
382 m |= Modifier::BOLD;
383 }
384 if mask & MOD_ITALIC != 0 {
385 m |= Modifier::ITALIC;
386 }
387 if mask & MOD_STRIKE != 0 {
388 m |= Modifier::CROSSED_OUT;
389 }
390 m
391}
392
393pub(super) fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
394 match kind {
395 None => {
396 if is_sigil_region {
397 Style::default().fg(theme.gray.to_ratatui())
398 } else {
399 Style::default().fg(theme.fg.to_ratatui())
400 }
401 }
402 Some(ElementKind::Bold) => Style::default()
403 .fg(theme.accent.to_ratatui())
404 .add_modifier(Modifier::BOLD),
405 Some(ElementKind::Italic) => Style::default()
406 .fg(theme.fg_secondary.to_ratatui())
407 .add_modifier(Modifier::ITALIC),
408 Some(ElementKind::Strikethrough) => Style::default()
409 .fg(theme.fg_secondary.to_ratatui())
410 .add_modifier(Modifier::CROSSED_OUT),
411 Some(ElementKind::InlineCode) => Style::default()
412 .fg(theme.aqua.to_ratatui())
413 .bg(theme.bg_soft.to_ratatui()),
414 Some(ElementKind::Link) => Style::default()
415 .fg(theme.accent.to_ratatui())
416 .add_modifier(Modifier::UNDERLINED),
417 Some(ElementKind::Image) => Style::default()
418 .fg(theme.accent.to_ratatui())
419 .add_modifier(Modifier::ITALIC),
420 Some(ElementKind::HeadingH1) | Some(ElementKind::HeadingH2) => {
422 if is_sigil_region {
423 Style::default().fg(theme.gray.to_ratatui())
424 } else {
425 Style::default()
426 .fg(theme.fg_bright.to_ratatui())
427 .add_modifier(Modifier::BOLD)
428 }
429 }
430 Some(ElementKind::HeadingH3) => {
431 if is_sigil_region {
432 Style::default().fg(theme.gray.to_ratatui())
433 } else {
434 Style::default()
435 .fg(theme.yellow.to_ratatui())
436 .add_modifier(Modifier::BOLD)
437 }
438 }
439 Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
440 Some(ElementKind::WikiLink) => Style::default()
442 .fg(theme.blue.to_ratatui())
443 .add_modifier(Modifier::UNDERLINED),
444 Some(ElementKind::Label) => Style::default()
445 .fg(theme.color_tag.to_ratatui())
446 .add_modifier(Modifier::BOLD),
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::super::parse_incremental::LineConstructKind;
453 use super::*;
454 use ratatui::style::Modifier;
455 fn t() -> Theme {
456 Theme::default()
457 }
458 fn text(spans: &[Span]) -> String {
459 spans.iter().map(|s| s.content.as_ref()).collect()
460 }
461
462 #[test]
463 fn blockquote_lazy_continuation_carries_depth() {
464 let buf = ParsedBuffer::parse(&["> first".to_string(), "second".to_string()]);
469 assert_eq!(buf.lines[0].blockquote_depth(), Some(1));
470 assert_eq!(
471 buf.lines[1].blockquote_depth(),
472 Some(1),
473 "lazy continuation line must carry the blockquote depth"
474 );
475
476 let nested = ParsedBuffer::parse(&[">> a".to_string(), "b".to_string()]);
478 assert_eq!(nested.lines[0].blockquote_depth(), Some(2));
479 assert_eq!(nested.lines[1].blockquote_depth(), Some(2));
480
481 let ended =
483 ParsedBuffer::parse(&["> first".to_string(), String::new(), "plain".to_string()]);
484 assert_eq!(ended.lines[0].blockquote_depth(), Some(1));
485 assert_eq!(ended.lines[2].blockquote_depth(), None);
486 }
487
488 #[test]
489 fn indented_code_excludes_trailing_blank_keeps_interior() {
490 use super::super::parse_incremental::LineConstructKind::{Blank, IndentedCode, Plain};
491 let kinds = |lines: &[&str]| {
492 let owned: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
493 ParsedBuffer::parse(&owned).kinds
494 };
495 assert_eq!(
497 kinds(&[" code", "", "outro"]),
498 vec![IndentedCode, Blank, Plain]
499 );
500 assert_eq!(
502 kinds(&[" a", "", " b"]),
503 vec![IndentedCode, IndentedCode, IndentedCode]
504 );
505 assert_eq!(
507 kinds(&[" a", "", "", "outro"]),
508 vec![IndentedCode, Blank, Blank, Plain]
509 );
510 assert_eq!(
513 kinds(&[" Line 1", " Line 2", "Line 3"]),
514 vec![IndentedCode, IndentedCode, Plain]
515 );
516 assert_eq!(
518 kinds(&[" line 1", " line 2", "", "", " line 3"]),
519 vec![
520 IndentedCode,
521 IndentedCode,
522 IndentedCode,
523 IndentedCode,
524 IndentedCode
525 ]
526 );
527 }
528
529 #[test]
530 fn gutter_width_matches_rendered() {
531 for d in 1u8..=4 {
534 assert_eq!(
535 blockquote_gutter_width(d),
536 string_display_width(&blockquote_gutter(d)),
537 "gutter width/string disagree at depth {d}"
538 );
539 }
540 }
541
542 #[test]
543 fn blockquote_depth_and_sigil_end() {
544 let p = ParsedLine::parse("> hello");
545 assert_eq!(p.blockquote_depth(), Some(1));
546 assert_eq!(p.blockquote_sigil_end(), Some(2));
548
549 let p2 = ParsedLine::parse(">> deep");
550 assert_eq!(p2.blockquote_depth(), Some(2));
551
552 let plain = ParsedLine::parse("not a quote");
553 assert_eq!(plain.blockquote_depth(), None);
554 assert_eq!(plain.blockquote_sigil_end(), None);
555 }
556 #[test]
557 fn parse_bold_range() {
558 let e = MarkdownSpanner::parse_elements("**bold**");
559 let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
560 assert_eq!((b.start_char, b.end_char), (0, 8));
561 }
562 #[test]
563 fn parse_italic() {
564 assert!(
565 MarkdownSpanner::parse_elements("*hi*")
566 .iter()
567 .any(|e| e.kind == ElementKind::Italic)
568 );
569 }
570 #[test]
571 fn parse_strikethrough() {
572 let e = MarkdownSpanner::parse_elements("~~gone~~");
573 let s = e
574 .iter()
575 .find(|x| x.kind == ElementKind::Strikethrough)
576 .unwrap();
577 assert_eq!((s.start_char, s.end_char), (0, 8));
578 }
579 #[test]
580 fn strikethrough_renders_with_crossed_out_modifier() {
581 let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
582 assert_eq!(text(&s), "gone");
583 assert!(
584 s.iter()
585 .any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
586 );
587 }
588 #[test]
589 fn parse_inline_code() {
590 assert!(
591 MarkdownSpanner::parse_elements("`x`")
592 .iter()
593 .any(|e| e.kind == ElementKind::InlineCode)
594 );
595 }
596 #[test]
597 fn parse_link() {
598 assert!(
599 MarkdownSpanner::parse_elements("[t](u)")
600 .iter()
601 .any(|e| e.kind == ElementKind::Link)
602 );
603 }
604
605 #[test]
606 fn parse_image_emits_image_element_and_placeholder() {
607 let line = "see  here";
608 let parsed = ParsedLine::parse(line);
609 let img = parsed
610 .elements
611 .iter()
612 .find(|e| e.kind == ElementKind::Image)
613 .expect("image element");
614 assert_eq!(line.chars().nth(img.start_char), Some('!'));
615 assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
616 let ph = parsed
617 .image_placeholders
618 .iter()
619 .find(|p| p.start_char == img.start_char)
620 .expect("placeholder for image");
621 assert_eq!(ph.placeholder, "[img.png]");
622 for pos in img.start_char..img.end_char {
623 assert!(
624 !parsed.content_vis[pos],
625 "char {pos} should be hidden inside image span"
626 );
627 }
628 }
629
630 #[test]
631 fn render_image_substitutes_placeholder_text() {
632 let line = "before  after";
633 let parsed = ParsedLine::parse(line);
634 let spans =
635 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
636 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
637 assert!(
638 rendered.contains("[pic.gif]"),
639 "rendered text {rendered:?} should include placeholder"
640 );
641 assert!(
642 !rendered.contains("![alt]"),
643 "raw image syntax should not appear in rendered output: {rendered:?}"
644 );
645 }
646
647 #[test]
648 fn render_image_with_empty_alt_uses_filename() {
649 let line = "";
650 let parsed = ParsedLine::parse(line);
651 let spans =
652 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
653 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
654 assert_eq!(rendered, "[image.png]");
655 }
656
657 #[test]
658 fn rendered_cursor_col_accounts_for_placeholder_width() {
659 let line = "a  b";
661 let parsed = ParsedLine::parse(line);
662 let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
663 line,
664 &parsed,
665 0,
666 "a  b".chars().count(), true,
668 false,
669 );
670 assert_eq!(after_placeholder, 11);
672 }
673 #[test]
674 fn parse_h1() {
675 assert!(
676 MarkdownSpanner::parse_elements("# T")
677 .iter()
678 .any(|e| e.kind == ElementKind::HeadingH1)
679 );
680 }
681 #[test]
682 fn parse_h2() {
683 assert!(
684 MarkdownSpanner::parse_elements("## T")
685 .iter()
686 .any(|e| e.kind == ElementKind::HeadingH2)
687 );
688 }
689 #[test]
690 fn parse_h3() {
691 assert!(
692 MarkdownSpanner::parse_elements("### T")
693 .iter()
694 .any(|e| e.kind == ElementKind::HeadingH3)
695 );
696 }
697 #[test]
698 fn force_raw_no_styling() {
699 let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
700 assert_eq!(text(&s), "**x**");
701 assert!(
702 !s.iter()
703 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
704 );
705 }
706 #[test]
707 fn plain_text_passthrough() {
708 let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
709 assert_eq!(text(&s), "hi");
710 }
711 #[test]
712 fn bold_without_cursor_hides_markers() {
713 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
714 assert_eq!(text(&s), "bold");
715 assert!(
716 s.iter()
717 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
718 );
719 }
720 #[test]
721 fn bold_cursor_inside_shows_raw() {
722 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
723 assert_eq!(text(&s), "**bold**");
724 }
725 #[test]
726 fn bold_cursor_outside_stays_rendered() {
727 let line = "hello **bold** world";
728 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
729 assert!(!text(&s).contains("**"));
730 }
731 #[test]
732 fn italic_cursor_inside_shows_raw() {
733 let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
734 assert_eq!(text(&s), "*hi*");
735 }
736 #[test]
737 fn inline_code_hides_backticks() {
738 let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
739 assert_eq!(text(&s), "x");
740 }
741 #[test]
742 fn h1_first_line_contains_hash() {
743 let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
744 assert!(text(&s).contains('#'));
745 assert!(text(&s).contains('T'));
746 }
747 #[test]
748 fn continuation_line_no_hash() {
749 let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
750 assert!(!text(&s).contains('#'));
751 }
752 #[test]
753 fn unordered_list_shows_marker() {
754 let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
755 assert!(
756 text(&s).starts_with("- "),
757 "expected '- item', got '{}'",
758 text(&s)
759 );
760 assert!(text(&s).contains("item"));
761 }
762 #[test]
763 fn ordered_list_shows_marker() {
764 let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
765 assert!(
766 text(&s).starts_with("1. "),
767 "expected '1. item', got '{}'",
768 text(&s)
769 );
770 }
771 #[test]
772 fn nested_list_4space_link_rendered() {
773 let line = " - [my link](url)";
775 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
776 assert!(
779 s.iter()
780 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
781 "link text should be underlined on a 4-space-indented nested list item"
782 );
783 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
784 assert!(
785 rendered.contains("my link"),
786 "link display text should be visible; got {:?}",
787 rendered
788 );
789 assert!(
790 !rendered.contains("](url)"),
791 "link URL sigil should be hidden; got {:?}",
792 rendered
793 );
794 }
795
796 #[test]
797 fn nested_list_tab_bold_rendered() {
798 let line = "\t- **bold nested**";
799 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
800 assert!(
801 s.iter()
802 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
803 "bold text should be styled on a tab-indented nested list item"
804 );
805 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
806 assert!(
807 !rendered.contains("**"),
808 "bold markers should be hidden; got {:?}",
809 rendered
810 );
811 }
812
813 #[test]
814 fn nested_list_4space_wikilink_rendered() {
815 let line = " - [[Target Note]]";
816 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
817 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
818 assert!(
819 !rendered.contains("[["),
820 "wikilink brackets should be hidden; got {:?}",
821 rendered
822 );
823 assert!(
824 rendered.contains("Target Note"),
825 "wikilink target text should render; got {:?}",
826 rendered
827 );
828 }
829
830 #[test]
831 fn nested_list_2space_still_renders_link() {
832 let line = " - [link](url)";
834 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
835 assert!(
836 s.iter()
837 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
838 );
839 }
840
841 #[test]
842 fn empty_heading_shows_hash_sigil() {
843 let line = "# ";
844 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
845 assert!(
846 text(&s).contains('#'),
847 "hash sigil should render in empty heading"
848 );
849 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
850 assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
851 }
852 #[test]
853 fn empty_heading_hash_only_shows() {
854 let line = "#";
855 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
856 assert!(text(&s).contains('#'));
857 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
858 assert_eq!(col, 1);
859 }
860 #[test]
861 fn heading_trailing_spaces_are_rendered() {
862 let line = "# Hello ";
863 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
864 assert_eq!(
865 text(&s),
866 "# Hello ",
867 "trailing spaces in heading should render"
868 );
869 }
870 #[test]
871 fn heading_trailing_spaces_cursor_col_correct() {
872 let line = "# Hello ";
873 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
875 assert_eq!(
876 col, 9,
877 "cursor in trailing space of heading should map to rendered col 9"
878 );
879 }
880 #[test]
881 fn trailing_spaces_are_rendered() {
882 let line = "hello ";
883 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
884 assert_eq!(text(&s), "hello ");
885 }
886 #[test]
887 fn trailing_spaces_cursor_col_correct() {
888 let line = "hello ";
889 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
890 assert_eq!(col, 7);
891 }
892 #[test]
893 fn list_marker_on_continuation_line_hidden() {
894 let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
895 assert!(!text(&s).starts_with("- "));
896 }
897 #[test]
898 fn parsed_line_heading_sigil_end_empty_heading() {
899 let p = ParsedLine::parse("#");
901 assert_eq!(p.heading_sigil_end(), Some(1));
902 }
903 #[test]
904 fn parsed_line_heading_sigil_end_with_content() {
905 let p = ParsedLine::parse("# T");
907 assert_eq!(p.heading_sigil_end(), Some(2));
908 }
909 #[test]
910 fn parsed_line_reuse_matches_individual() {
911 let line = "**hello** world";
912 let parsed = ParsedLine::parse(line);
913 let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
914 let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
915 assert_eq!(
916 s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
917 s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
918 );
919 }
920
921 #[test]
924 fn parse_wikilink() {
925 let e = MarkdownSpanner::parse_elements("[[My Note]]");
926 let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
927 assert_eq!((wl.start_char, wl.end_char), (0, 11));
928 }
929
930 #[test]
931 fn wikilink_without_cursor_hides_brackets() {
932 let line = "[[My Note]]";
933 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
934 assert_eq!(text(&s), "My Note");
935 assert!(
936 s.iter()
937 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
938 );
939 }
940
941 #[test]
942 fn wikilink_cursor_inside_shows_brackets() {
943 let line = "[[My Note]]";
944 let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
946 assert_eq!(text(&s), "[[My Note]]");
947 }
948
949 #[test]
950 fn wikilink_cursor_outside_hides_brackets() {
951 let line = "hello [[My Note]] world";
952 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
953 assert!(!text(&s).contains("[["));
954 assert!(!text(&s).contains("]]"));
955 }
956
957 #[test]
958 fn wikilink_in_heading_rendered() {
959 let line = "# See [[Topic]]";
960 let e = MarkdownSpanner::parse_elements(line);
961 assert!(
962 e.iter().any(|x| x.kind == ElementKind::WikiLink),
963 "wikilink inside heading should produce a WikiLink element"
964 );
965 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
966 assert_eq!(text(&s), "# See Topic", "wikilink brackets hidden, # kept");
967 }
968
969 #[test]
970 fn heading_with_link_does_not_leak_bracket() {
971 let line = "# [text](http://x)";
972 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
973 assert_eq!(text(&s), "# text");
976 }
977
978 #[test]
979 fn heading_with_bold_does_not_leak_asterisk() {
980 let line = "# **bold**";
981 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
982 assert_eq!(
983 text(&s),
984 "# bold",
985 "leading * must not leak into heading sigil"
986 );
987 }
988
989 #[test]
990 fn bold_wikilink_is_bold() {
991 let line = "**[[Topic]]**";
992 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
993 assert_eq!(text(&s), "Topic");
994 assert!(
995 s.iter()
996 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)
997 && sp.content.contains("Topic")),
998 "wikilink wrapped in ** must render bold"
999 );
1000 }
1001
1002 #[test]
1003 fn italic_link_is_italic() {
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");
1007 assert!(
1008 s.iter()
1009 .any(|sp| sp.style.add_modifier.contains(Modifier::ITALIC)
1010 && sp.content.contains("text")),
1011 "link wrapped in * must render italic"
1012 );
1013 }
1014
1015 #[test]
1016 fn bold_italic_wikilink_is_bold_and_italic() {
1017 let line = "***[[Topic]]***";
1018 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1019 assert_eq!(text(&s), "Topic");
1020 assert!(
1021 s.iter().any(|sp| {
1022 sp.content.contains("Topic")
1023 && sp.style.add_modifier.contains(Modifier::BOLD)
1024 && sp.style.add_modifier.contains(Modifier::ITALIC)
1025 }),
1026 "wikilink in *** *** must render both bold and italic"
1027 );
1028 }
1029
1030 #[test]
1031 fn bold_italic_plain_text() {
1032 let line = "***text***";
1033 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1034 assert_eq!(text(&s), "text");
1035 assert!(
1036 s.iter().any(|sp| {
1037 sp.content.contains("text")
1038 && sp.style.add_modifier.contains(Modifier::BOLD)
1039 && sp.style.add_modifier.contains(Modifier::ITALIC)
1040 }),
1041 "*** *** must render both bold and italic"
1042 );
1043 }
1044
1045 #[test]
1046 fn wikilink_mid_sentence() {
1047 let line = "See [[Topic]] for details";
1048 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
1049 assert_eq!(text(&s), "See Topic for details");
1050 }
1051
1052 #[test]
1053 fn wikilink_cursor_col_accounts_for_brackets() {
1054 let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
1057 assert_eq!(col, 2);
1058
1059 let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
1063 assert_eq!(col2, 0);
1064 }
1065
1066 #[test]
1067 fn buffer_parse_nested_list_under_parent() {
1068 let lines = vec".to_string(),
1072 ];
1073 let parsed = ParsedBuffer::parse(&lines).lines;
1074 assert_eq!(parsed.len(), 2);
1075
1076 assert_eq!(parsed[0].list_sigil_end(), Some(2));
1078
1079 assert_eq!(
1081 parsed[1].list_sigil_end(),
1082 Some(6),
1083 "child's sigil_end should be after ' - ' (6 chars)"
1084 );
1085
1086 assert!(
1088 parsed[1]
1089 .elements
1090 .iter()
1091 .any(|e| e.kind == ElementKind::Link),
1092 "nested list item should contain a Link element"
1093 );
1094 }
1095
1096 #[test]
1097 fn buffer_parse_standalone_2space_list_still_works() {
1098 let lines = vec".to_string()];
1100 let parsed = ParsedBuffer::parse(&lines).lines;
1101 assert!(
1102 parsed[0]
1103 .elements
1104 .iter()
1105 .any(|e| e.kind == ElementKind::Link)
1106 );
1107 assert_eq!(parsed[0].list_sigil_end(), Some(4));
1108 }
1109
1110 #[test]
1111 fn buffer_parse_top_level_unchanged() {
1112 let lines = vec".to_string()];
1114 let parsed = ParsedBuffer::parse(&lines).lines;
1115 assert!(
1116 parsed[0]
1117 .elements
1118 .iter()
1119 .any(|e| e.kind == ElementKind::Link)
1120 );
1121 assert_eq!(parsed[0].list_sigil_end(), Some(2));
1122 }
1123
1124 #[test]
1125 fn buffer_parse_empty_lines_preserved() {
1126 let lines = vec![
1127 "# Title".to_string(),
1128 String::new(),
1129 "paragraph".to_string(),
1130 ];
1131 let parsed = ParsedBuffer::parse(&lines).lines;
1132 assert_eq!(parsed.len(), 3);
1133 assert_eq!(parsed[1].elements.len(), 0);
1134 assert_eq!(parsed[1].content_vis.len(), 0);
1135 }
1136
1137 #[test]
1138 fn buffer_parse_ordered_nested_list() {
1139 let lines = vec!["1. first".to_string(), " 1. nested".to_string()];
1140 let parsed = ParsedBuffer::parse(&lines).lines;
1141 assert_eq!(parsed[0].list_sigil_end(), Some(3));
1142 assert_eq!(parsed[1].list_sigil_end(), Some(7));
1143 }
1144
1145 #[test]
1146 fn buffer_parse_setext_h1_spans_two_rows() {
1147 let lines = vec!["My Heading".to_string(), "==========".to_string()];
1153 let parsed = ParsedBuffer::parse(&lines).lines;
1154 assert!(
1155 parsed[0]
1156 .elements
1157 .iter()
1158 .any(|e| e.kind == ElementKind::HeadingH1),
1159 "setext underline must tag row 0 as HeadingH1"
1160 );
1161 assert!(
1162 parsed[1]
1163 .elements
1164 .iter()
1165 .any(|e| e.kind == ElementKind::HeadingH1),
1166 "setext underline must tag row 1 as HeadingH1"
1167 );
1168 assert!(
1170 parsed[1].content_vis.iter().all(|v| !v),
1171 "setext underline row has no content"
1172 );
1173 }
1174
1175 #[test]
1176 fn buffer_parse_multiline_blockquote() {
1177 let lines = vec!["> first line".to_string(), "> second line".to_string()];
1180 let parsed = ParsedBuffer::parse(&lines).lines;
1181 assert!(
1182 parsed[0]
1183 .elements
1184 .iter()
1185 .any(|e| e.kind == ElementKind::Blockquote),
1186 "row 0 must tag as Blockquote"
1187 );
1188 assert!(
1189 parsed[1]
1190 .elements
1191 .iter()
1192 .any(|e| e.kind == ElementKind::Blockquote),
1193 "row 1 must tag as Blockquote"
1194 );
1195 }
1196
1197 #[test]
1198 fn parse_line_emits_label_for_hashtag() {
1199 let line = "see #rust later";
1200 let parsed = ParsedLine::parse(line);
1201 let label = parsed
1202 .elements
1203 .iter()
1204 .find(|e| matches!(e.kind, ElementKind::Label));
1205 assert!(
1206 label.is_some(),
1207 "expected Label element: {:?}",
1208 parsed.elements
1209 );
1210 let l = label.unwrap();
1211 let span: String = line
1212 .chars()
1213 .skip(l.start_char)
1214 .take(l.end_char - l.start_char)
1215 .collect();
1216 assert_eq!(span, "#rust");
1217 }
1218
1219 #[test]
1220 fn parse_line_skips_label_inside_inline_code() {
1221 let parsed = ParsedLine::parse("use `#foo` here");
1222 let has_label = parsed
1223 .elements
1224 .iter()
1225 .any(|e| matches!(e.kind, ElementKind::Label));
1226 assert!(!has_label, "should not emit Label inside inline code");
1227 }
1228
1229 #[test]
1232 fn parse_line_skips_label_inside_markdown_link() {
1233 let parsed = ParsedLine::parse("[see docs](#section) and #real");
1234 let labels: Vec<_> = parsed
1235 .elements
1236 .iter()
1237 .filter(|e| matches!(e.kind, ElementKind::Label))
1238 .collect();
1239 assert_eq!(
1240 labels.len(),
1241 1,
1242 "only #real should be a label, not #section in the link"
1243 );
1244 let l = labels[0];
1245 let span: String = "[see docs](#section) and #real"
1246 .chars()
1247 .skip(l.start_char)
1248 .take(l.end_char - l.start_char)
1249 .collect();
1250 assert_eq!(span, "#real");
1251 }
1252
1253 #[test]
1254 fn parse_line_skips_label_inside_link_display_text() {
1255 let parsed = ParsedLine::parse("[#todo](notes/project.md)");
1256 let has_label = parsed
1257 .elements
1258 .iter()
1259 .any(|e| matches!(e.kind, ElementKind::Label));
1260 assert!(
1261 !has_label,
1262 "hashtag inside link display text should not become Label"
1263 );
1264 }
1265
1266 #[test]
1267 fn parse_line_skips_label_after_label_char() {
1268 let parsed = ParsedLine::parse("foo#bar baz");
1269 let has_label = parsed
1270 .elements
1271 .iter()
1272 .any(|e| matches!(e.kind, ElementKind::Label));
1273 assert!(
1274 !has_label,
1275 "word#tag should not emit Label without word boundary"
1276 );
1277 }
1278
1279 #[test]
1280 fn parse_line_skips_label_for_double_hash() {
1281 let parsed = ParsedLine::parse("##draft");
1285 let has_label = parsed
1286 .elements
1287 .iter()
1288 .any(|e| matches!(e.kind, ElementKind::Label));
1289 assert!(!has_label, "##draft should not emit Label");
1290 }
1291
1292 #[test]
1293 fn parse_line_skips_label_for_adjacent_hash_run() {
1294 let parsed = ParsedLine::parse("#tag#more");
1298 let labels: Vec<_> = parsed
1299 .elements
1300 .iter()
1301 .filter(|e| matches!(e.kind, ElementKind::Label))
1302 .collect();
1303 assert!(
1304 labels.is_empty(),
1305 "#tag#more should not emit Label, got {:?}",
1306 labels
1307 );
1308 }
1309
1310 #[test]
1311 fn parse_buffer_skips_label_inside_fenced_block() {
1312 let buffer = vec![
1313 "before".to_string(),
1314 "```".to_string(),
1315 "#inside".to_string(),
1316 "```".to_string(),
1317 "after #outside".to_string(),
1318 ];
1319 let lines = ParsedBuffer::parse(&buffer).lines;
1320 let inside_labels: Vec<_> = lines[2]
1321 .elements
1322 .iter()
1323 .filter(|e| matches!(e.kind, ElementKind::Label))
1324 .collect();
1325 assert!(
1326 inside_labels.is_empty(),
1327 "no Label emitted for hashtags in fenced blocks"
1328 );
1329
1330 let outside_labels: Vec<_> = lines[4]
1331 .elements
1332 .iter()
1333 .filter(|e| matches!(e.kind, ElementKind::Label))
1334 .collect();
1335 assert_eq!(outside_labels.len(), 1, "#outside still extracted");
1336 }
1337
1338 #[test]
1339 fn parse_range_full_equals_parse() {
1340 let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
1341 let full = ParsedBuffer::parse(&lines);
1342 let range_full = ParsedBuffer::parse_range(&lines, 0..lines.len());
1343 assert_eq!(full.lines.len(), range_full.lines.len());
1344 assert_eq!(full.kinds, range_full.kinds);
1345 for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
1346 assert_eq!(a.content_vis, b.content_vis);
1347 assert_eq!(a.elements.len(), b.elements.len());
1348 }
1349 }
1350
1351 #[test]
1352 fn parse_range_paragraph_only_slice() {
1353 let lines: Vec<String> = vec![
1354 "intro paragraph".into(),
1355 "".into(),
1356 "middle line".into(),
1357 "".into(),
1358 "outro".into(),
1359 ];
1360 let slice = ParsedBuffer::parse_range(&lines, 2..3);
1361 assert_eq!(slice.lines.len(), 1);
1362 assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
1363 }
1364
1365 #[test]
1366 fn splice_replaces_range() {
1367 let mut pb = ParsedBuffer::parse(&["alpha".into(), "beta".into(), "gamma".into()]);
1368 let replacement = ParsedBuffer::parse(&["BETA-NEW".into()]);
1369 let replacement_kind = replacement.kinds[0];
1370 pb.splice(1..2, replacement);
1371 assert_eq!(pb.lines.len(), 3);
1372 assert_eq!(pb.kinds.len(), 3);
1373 assert_eq!(
1374 pb.kinds[1], replacement_kind,
1375 "replacement landed at the wrong index"
1376 );
1377 }
1378
1379 #[cfg(debug_assertions)]
1380 #[test]
1381 #[should_panic(expected = "splice")]
1382 fn splice_panics_on_length_mismatch_in_debug() {
1383 let mut pb = ParsedBuffer::parse(&["a".into(), "b".into()]);
1384 let too_short = ParsedBuffer::parse(&["X".into()]);
1385 pb.splice(0..2, too_short);
1386 }
1387
1388 #[test]
1399 fn lazy_depth_blockquote_closes_at_first_blank() {
1400 let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
1401 let pb = ParsedBuffer::parse(&lines);
1402 assert_eq!(
1403 pb.lazy_depth,
1404 vec![1, 0, 0, 0],
1405 "blockquote closes at first blank per CommonMark §5.1; got {:?}",
1406 pb.lazy_depth,
1407 );
1408 }
1409
1410 #[test]
1416 fn lazy_depth_indented_code_across_blanks() {
1417 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1418 let pb = ParsedBuffer::parse(&lines);
1419 assert_eq!(
1420 pb.lazy_depth,
1421 vec![1, 1, 1],
1422 "indented code multi-chunk should keep lazy_depth > 0 across the blank \
1423 AND through the last content row; got {:?}",
1424 pb.lazy_depth,
1425 );
1426 }
1427
1428 #[test]
1432 fn lazy_depth_fenced_code_does_not_count() {
1433 let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
1434 let pb = ParsedBuffer::parse(&lines);
1435 assert_eq!(
1436 pb.lazy_depth,
1437 vec![0, 0, 0, 0],
1438 "fenced code is not lazy-continuable; got {:?}",
1439 pb.lazy_depth,
1440 );
1441 }
1442
1443 #[test]
1454 fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
1455 let lines: Vec<String> = vec!["> a".into(), "".into()];
1456 let pb = ParsedBuffer::parse(&lines);
1457 assert_eq!(
1458 pb.lazy_depth,
1459 vec![1, 0],
1460 "blockquote must close at the trailing blank; got {:?}",
1461 pb.lazy_depth,
1462 );
1463 assert!(
1464 pb.reset_boundaries.contains(&1),
1465 "the trailing blank at row 1 must be a reset boundary; got {:?}",
1466 pb.reset_boundaries,
1467 );
1468 }
1469
1470 #[test]
1481 fn boundaries_skip_rows_inside_lazy_block() {
1482 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1483 let pb = ParsedBuffer::parse(&lines);
1484 assert_eq!(
1485 pb.reset_boundaries,
1486 vec![0, lines.len()],
1487 "no boundary should land on a blank row inside the open indented-code block; \
1488 got {:?}",
1489 pb.reset_boundaries,
1490 );
1491 }
1492}