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 cluster_display_width(cluster: &str) -> usize {
40 cluster
41 .chars()
42 .next()
43 .and_then(unicode_width::UnicodeWidthChar::width)
44 .unwrap_or(1)
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct Element {
49 pub start_char: usize,
50 pub end_char: usize,
51 pub kind: ElementKind,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub enum ElementKind {
56 Bold,
57 Italic,
58 Strikethrough,
59 InlineCode,
60 Link,
61 HeadingH1,
62 HeadingH2,
63 HeadingH3,
64 Blockquote,
65 WikiLink,
66 Image,
67 Label,
68}
69
70#[derive(Debug, Clone)]
75pub struct ImagePlaceholder {
76 pub start_char: usize,
77 pub end_char: usize,
78 pub placeholder: String,
79 pub placeholder_width: usize,
80}
81
82#[derive(Debug, Clone)]
86pub struct ParsedLine {
87 pub elements: Vec<Element>,
88 pub content_vis: Vec<bool>,
90 elem_vis: Vec<bool>,
93 elem_index: Vec<u16>,
96 list_sigil_end: Option<usize>,
99 pub image_placeholders: Vec<ImagePlaceholder>,
103}
104
105impl ParsedLine {
106 pub fn parse(line: &str) -> Self {
116 let owned = line.to_string();
117 if needs_synthetic_list_parent(line) {
118 ParsedBuffer::parse(&["- ".to_string(), owned])
121 .lines
122 .pop()
123 .expect("ParsedBuffer::parse returns one row per input line")
124 } else {
125 ParsedBuffer::parse(std::slice::from_ref(&owned))
126 .lines
127 .pop()
128 .expect("ParsedBuffer::parse always returns at least one ParsedLine")
129 }
130 }
131
132 pub fn elem_at(&self, pos: usize) -> Option<usize> {
134 self.elem_index.get(pos).and_then(|&tag| {
135 if tag == 0 {
136 None
137 } else {
138 Some((tag as usize) - 1)
139 }
140 })
141 }
142
143 pub fn in_any_element(&self, pos: usize) -> bool {
145 self.elem_vis.get(pos).copied().unwrap_or(false)
146 }
147
148 pub fn heading_sigil_end(&self) -> Option<usize> {
155 self.elements
156 .iter()
157 .find(|e| {
158 matches!(
159 e.kind,
160 ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
161 )
162 })
163 .map(|e| {
164 let mut first_content = e.end_char; for i in e.start_char..e.end_char {
166 if i < self.content_vis.len() && self.content_vis[i] {
167 first_content = i;
168 break;
169 }
170 }
171 first_content
172 })
173 }
174
175 pub fn list_sigil_end(&self) -> Option<usize> {
178 self.list_sigil_end
179 }
180
181 #[cfg(debug_assertions)]
186 pub(super) fn debug_assert_eq_to(&self, other: &Self, row: usize) {
187 assert_eq!(
188 self.content_vis, other.content_vis,
189 "row {row} content_vis diverge"
190 );
191 assert_eq!(self.elem_vis, other.elem_vis, "row {row} elem_vis diverge");
192 assert_eq!(
193 self.elem_index, other.elem_index,
194 "row {row} elem_index diverge"
195 );
196 assert_eq!(
197 self.list_sigil_end, other.list_sigil_end,
198 "row {row} list_sigil_end diverge"
199 );
200 assert_eq!(
201 self.elements.len(),
202 other.elements.len(),
203 "row {row} elements.len() diverge"
204 );
205 }
206}
207
208fn needs_synthetic_list_parent(line: &str) -> bool {
213 let trimmed = line.trim_start_matches([' ', '\t']);
214 if trimmed.len() == line.len() {
215 return false; }
217 list_marker_len(trimmed).is_some()
218}
219
220pub(super) fn leading_ws_byte_len(line: &str) -> usize {
228 line.bytes()
229 .take_while(|b| *b == b' ' || *b == b'\t')
230 .count()
231}
232
233pub(super) fn tag_to_kind(tag: &Tag) -> Option<ElementKind> {
238 Some(match tag {
239 Tag::Strong => ElementKind::Bold,
240 Tag::Emphasis => ElementKind::Italic,
241 Tag::Strikethrough => ElementKind::Strikethrough,
242 Tag::Link { .. } => ElementKind::Link,
243 Tag::BlockQuote(_) => ElementKind::Blockquote,
244 Tag::Heading { level, .. } => match level {
245 HeadingLevel::H1 => ElementKind::HeadingH1,
246 HeadingLevel::H2 => ElementKind::HeadingH2,
247 _ => ElementKind::HeadingH3,
248 },
249 _ => return None,
250 })
251}
252
253pub(super) fn list_marker_len(s: &str) -> Option<usize> {
254 if s.starts_with("- ") || s.starts_with("* ") || s.starts_with("+ ") {
255 return Some(2);
256 }
257 let bytes = s.as_bytes();
258 let mut i = 0;
259 while i < bytes.len() && bytes[i].is_ascii_digit() {
260 i += 1;
261 }
262 if i > 0 && i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b' ' {
263 Some(i + 2)
264 } else {
265 None
266 }
267}
268
269pub(super) fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
270 match kind {
271 None => {
272 if is_sigil_region {
273 Style::default().fg(theme.fg_muted.to_ratatui())
274 } else {
275 Style::default().fg(theme.fg.to_ratatui())
276 }
277 }
278 Some(ElementKind::Bold) => Style::default()
279 .fg(theme.accent.to_ratatui())
280 .add_modifier(Modifier::BOLD),
281 Some(ElementKind::Italic) => Style::default()
282 .fg(theme.fg_secondary.to_ratatui())
283 .add_modifier(Modifier::ITALIC),
284 Some(ElementKind::Strikethrough) => Style::default()
285 .fg(theme.fg_secondary.to_ratatui())
286 .add_modifier(Modifier::CROSSED_OUT),
287 Some(ElementKind::InlineCode) => Style::default()
288 .fg(theme.fg.to_ratatui())
289 .bg(theme.bg_selected.to_ratatui()),
290 Some(ElementKind::Link) => Style::default()
291 .fg(theme.accent.to_ratatui())
292 .add_modifier(Modifier::UNDERLINED),
293 Some(ElementKind::Image) => Style::default()
294 .fg(theme.accent.to_ratatui())
295 .add_modifier(Modifier::ITALIC),
296 Some(ElementKind::HeadingH1) => {
297 if is_sigil_region {
298 Style::default().fg(theme.fg_muted.to_ratatui())
299 } else {
300 Style::default()
301 .fg(theme.accent.to_ratatui())
302 .add_modifier(Modifier::BOLD)
303 }
304 }
305 Some(ElementKind::HeadingH2) => {
306 if is_sigil_region {
307 Style::default().fg(theme.fg_muted.to_ratatui())
308 } else {
309 Style::default()
310 .fg(theme.fg.to_ratatui())
311 .add_modifier(Modifier::BOLD)
312 }
313 }
314 Some(ElementKind::HeadingH3) => {
315 if is_sigil_region {
316 Style::default().fg(theme.fg_muted.to_ratatui())
317 } else {
318 Style::default().fg(theme.fg_secondary.to_ratatui())
319 }
320 }
321 Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
322 Some(ElementKind::WikiLink) => Style::default()
323 .fg(theme.color_directory.to_ratatui())
324 .add_modifier(Modifier::UNDERLINED),
325 Some(ElementKind::Label) => Style::default()
326 .fg(theme.color_tag.to_ratatui())
327 .add_modifier(Modifier::BOLD),
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::super::parse_incremental::LineConstructKind;
334 use super::*;
335 use ratatui::style::Modifier;
336 fn t() -> Theme {
337 Theme::default()
338 }
339 fn text(spans: &[Span]) -> String {
340 spans.iter().map(|s| s.content.as_ref()).collect()
341 }
342
343 #[test]
344 fn parse_bold_range() {
345 let e = MarkdownSpanner::parse_elements("**bold**");
346 let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
347 assert_eq!((b.start_char, b.end_char), (0, 8));
348 }
349 #[test]
350 fn parse_italic() {
351 assert!(
352 MarkdownSpanner::parse_elements("*hi*")
353 .iter()
354 .any(|e| e.kind == ElementKind::Italic)
355 );
356 }
357 #[test]
358 fn parse_strikethrough() {
359 let e = MarkdownSpanner::parse_elements("~~gone~~");
360 let s = e
361 .iter()
362 .find(|x| x.kind == ElementKind::Strikethrough)
363 .unwrap();
364 assert_eq!((s.start_char, s.end_char), (0, 8));
365 }
366 #[test]
367 fn strikethrough_renders_with_crossed_out_modifier() {
368 let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
369 assert_eq!(text(&s), "gone");
370 assert!(
371 s.iter()
372 .any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
373 );
374 }
375 #[test]
376 fn parse_inline_code() {
377 assert!(
378 MarkdownSpanner::parse_elements("`x`")
379 .iter()
380 .any(|e| e.kind == ElementKind::InlineCode)
381 );
382 }
383 #[test]
384 fn parse_link() {
385 assert!(
386 MarkdownSpanner::parse_elements("[t](u)")
387 .iter()
388 .any(|e| e.kind == ElementKind::Link)
389 );
390 }
391
392 #[test]
393 fn parse_image_emits_image_element_and_placeholder() {
394 let line = "see  here";
395 let parsed = ParsedLine::parse(line);
396 let img = parsed
397 .elements
398 .iter()
399 .find(|e| e.kind == ElementKind::Image)
400 .expect("image element");
401 assert_eq!(line.chars().nth(img.start_char), Some('!'));
402 assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
403 let ph = parsed
404 .image_placeholders
405 .iter()
406 .find(|p| p.start_char == img.start_char)
407 .expect("placeholder for image");
408 assert_eq!(ph.placeholder, "[img.png]");
409 for pos in img.start_char..img.end_char {
410 assert!(
411 !parsed.content_vis[pos],
412 "char {pos} should be hidden inside image span"
413 );
414 }
415 }
416
417 #[test]
418 fn render_image_substitutes_placeholder_text() {
419 let line = "before  after";
420 let parsed = ParsedLine::parse(line);
421 let spans =
422 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
423 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
424 assert!(
425 rendered.contains("[pic.gif]"),
426 "rendered text {rendered:?} should include placeholder"
427 );
428 assert!(
429 !rendered.contains("![alt]"),
430 "raw image syntax should not appear in rendered output: {rendered:?}"
431 );
432 }
433
434 #[test]
435 fn render_image_with_empty_alt_uses_filename() {
436 let line = "";
437 let parsed = ParsedLine::parse(line);
438 let spans =
439 MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
440 let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
441 assert_eq!(rendered, "[image.png]");
442 }
443
444 #[test]
445 fn rendered_cursor_col_accounts_for_placeholder_width() {
446 let line = "a  b";
448 let parsed = ParsedLine::parse(line);
449 let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
450 line,
451 &parsed,
452 0,
453 "a  b".chars().count(), true,
455 false,
456 );
457 assert_eq!(after_placeholder, 11);
459 }
460 #[test]
461 fn parse_h1() {
462 assert!(
463 MarkdownSpanner::parse_elements("# T")
464 .iter()
465 .any(|e| e.kind == ElementKind::HeadingH1)
466 );
467 }
468 #[test]
469 fn parse_h2() {
470 assert!(
471 MarkdownSpanner::parse_elements("## T")
472 .iter()
473 .any(|e| e.kind == ElementKind::HeadingH2)
474 );
475 }
476 #[test]
477 fn parse_h3() {
478 assert!(
479 MarkdownSpanner::parse_elements("### T")
480 .iter()
481 .any(|e| e.kind == ElementKind::HeadingH3)
482 );
483 }
484 #[test]
485 fn force_raw_no_styling() {
486 let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
487 assert_eq!(text(&s), "**x**");
488 assert!(
489 !s.iter()
490 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
491 );
492 }
493 #[test]
494 fn plain_text_passthrough() {
495 let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
496 assert_eq!(text(&s), "hi");
497 }
498 #[test]
499 fn bold_without_cursor_hides_markers() {
500 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
501 assert_eq!(text(&s), "bold");
502 assert!(
503 s.iter()
504 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
505 );
506 }
507 #[test]
508 fn bold_cursor_inside_shows_raw() {
509 let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
510 assert_eq!(text(&s), "**bold**");
511 }
512 #[test]
513 fn bold_cursor_outside_stays_rendered() {
514 let line = "hello **bold** world";
515 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
516 assert!(!text(&s).contains("**"));
517 }
518 #[test]
519 fn italic_cursor_inside_shows_raw() {
520 let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
521 assert_eq!(text(&s), "*hi*");
522 }
523 #[test]
524 fn inline_code_hides_backticks() {
525 let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
526 assert_eq!(text(&s), "x");
527 }
528 #[test]
529 fn h1_first_line_contains_hash() {
530 let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
531 assert!(text(&s).contains('#'));
532 assert!(text(&s).contains('T'));
533 }
534 #[test]
535 fn continuation_line_no_hash() {
536 let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
537 assert!(!text(&s).contains('#'));
538 }
539 #[test]
540 fn unordered_list_shows_marker() {
541 let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
542 assert!(
543 text(&s).starts_with("- "),
544 "expected '- item', got '{}'",
545 text(&s)
546 );
547 assert!(text(&s).contains("item"));
548 }
549 #[test]
550 fn ordered_list_shows_marker() {
551 let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
552 assert!(
553 text(&s).starts_with("1. "),
554 "expected '1. item', got '{}'",
555 text(&s)
556 );
557 }
558 #[test]
559 fn nested_list_4space_link_rendered() {
560 let line = " - [my link](url)";
562 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
563 assert!(
566 s.iter()
567 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
568 "link text should be underlined on a 4-space-indented nested list item"
569 );
570 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
571 assert!(
572 rendered.contains("my link"),
573 "link display text should be visible; got {:?}",
574 rendered
575 );
576 assert!(
577 !rendered.contains("](url)"),
578 "link URL sigil should be hidden; got {:?}",
579 rendered
580 );
581 }
582
583 #[test]
584 fn nested_list_tab_bold_rendered() {
585 let line = "\t- **bold nested**";
586 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
587 assert!(
588 s.iter()
589 .any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
590 "bold text should be styled on a tab-indented nested list item"
591 );
592 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
593 assert!(
594 !rendered.contains("**"),
595 "bold markers should be hidden; got {:?}",
596 rendered
597 );
598 }
599
600 #[test]
601 fn nested_list_4space_wikilink_rendered() {
602 let line = " - [[Target Note]]";
603 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
604 let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
605 assert!(
606 !rendered.contains("[["),
607 "wikilink brackets should be hidden; got {:?}",
608 rendered
609 );
610 assert!(
611 rendered.contains("Target Note"),
612 "wikilink target text should render; got {:?}",
613 rendered
614 );
615 }
616
617 #[test]
618 fn nested_list_2space_still_renders_link() {
619 let line = " - [link](url)";
621 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
622 assert!(
623 s.iter()
624 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
625 );
626 }
627
628 #[test]
629 fn empty_heading_shows_hash_sigil() {
630 let line = "# ";
631 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
632 assert!(
633 text(&s).contains('#'),
634 "hash sigil should render in empty heading"
635 );
636 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
637 assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
638 }
639 #[test]
640 fn empty_heading_hash_only_shows() {
641 let line = "#";
642 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
643 assert!(text(&s).contains('#'));
644 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
645 assert_eq!(col, 1);
646 }
647 #[test]
648 fn heading_trailing_spaces_are_rendered() {
649 let line = "# Hello ";
650 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
651 assert_eq!(
652 text(&s),
653 "# Hello ",
654 "trailing spaces in heading should render"
655 );
656 }
657 #[test]
658 fn heading_trailing_spaces_cursor_col_correct() {
659 let line = "# Hello ";
660 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
662 assert_eq!(
663 col, 9,
664 "cursor in trailing space of heading should map to rendered col 9"
665 );
666 }
667 #[test]
668 fn trailing_spaces_are_rendered() {
669 let line = "hello ";
670 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
671 assert_eq!(text(&s), "hello ");
672 }
673 #[test]
674 fn trailing_spaces_cursor_col_correct() {
675 let line = "hello ";
676 let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
677 assert_eq!(col, 7);
678 }
679 #[test]
680 fn list_marker_on_continuation_line_hidden() {
681 let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
682 assert!(!text(&s).starts_with("- "));
683 }
684 #[test]
685 fn parsed_line_heading_sigil_end_empty_heading() {
686 let p = ParsedLine::parse("#");
688 assert_eq!(p.heading_sigil_end(), Some(1));
689 }
690 #[test]
691 fn parsed_line_heading_sigil_end_with_content() {
692 let p = ParsedLine::parse("# T");
694 assert_eq!(p.heading_sigil_end(), Some(2));
695 }
696 #[test]
697 fn parsed_line_reuse_matches_individual() {
698 let line = "**hello** world";
699 let parsed = ParsedLine::parse(line);
700 let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
701 let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
702 assert_eq!(
703 s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
704 s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
705 );
706 }
707
708 #[test]
711 fn parse_wikilink() {
712 let e = MarkdownSpanner::parse_elements("[[My Note]]");
713 let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
714 assert_eq!((wl.start_char, wl.end_char), (0, 11));
715 }
716
717 #[test]
718 fn wikilink_without_cursor_hides_brackets() {
719 let line = "[[My Note]]";
720 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
721 assert_eq!(text(&s), "My Note");
722 assert!(
723 s.iter()
724 .any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
725 );
726 }
727
728 #[test]
729 fn wikilink_cursor_inside_shows_brackets() {
730 let line = "[[My Note]]";
731 let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
733 assert_eq!(text(&s), "[[My Note]]");
734 }
735
736 #[test]
737 fn wikilink_cursor_outside_hides_brackets() {
738 let line = "hello [[My Note]] world";
739 let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
740 assert!(!text(&s).contains("[["));
741 assert!(!text(&s).contains("]]"));
742 }
743
744 #[test]
745 fn wikilink_mid_sentence() {
746 let line = "See [[Topic]] for details";
747 let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
748 assert_eq!(text(&s), "See Topic for details");
749 }
750
751 #[test]
752 fn wikilink_cursor_col_accounts_for_brackets() {
753 let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
756 assert_eq!(col, 2);
757
758 let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
762 assert_eq!(col2, 0);
763 }
764
765 #[test]
766 fn buffer_parse_nested_list_under_parent() {
767 let lines = vec".to_string(),
771 ];
772 let parsed = ParsedBuffer::parse(&lines).lines;
773 assert_eq!(parsed.len(), 2);
774
775 assert_eq!(parsed[0].list_sigil_end(), Some(2));
777
778 assert_eq!(
780 parsed[1].list_sigil_end(),
781 Some(6),
782 "child's sigil_end should be after ' - ' (6 chars)"
783 );
784
785 assert!(
787 parsed[1]
788 .elements
789 .iter()
790 .any(|e| e.kind == ElementKind::Link),
791 "nested list item should contain a Link element"
792 );
793 }
794
795 #[test]
796 fn buffer_parse_standalone_2space_list_still_works() {
797 let lines = vec".to_string()];
799 let parsed = ParsedBuffer::parse(&lines).lines;
800 assert!(
801 parsed[0]
802 .elements
803 .iter()
804 .any(|e| e.kind == ElementKind::Link)
805 );
806 assert_eq!(parsed[0].list_sigil_end(), Some(4));
807 }
808
809 #[test]
810 fn buffer_parse_top_level_unchanged() {
811 let lines = vec".to_string()];
813 let parsed = ParsedBuffer::parse(&lines).lines;
814 assert!(
815 parsed[0]
816 .elements
817 .iter()
818 .any(|e| e.kind == ElementKind::Link)
819 );
820 assert_eq!(parsed[0].list_sigil_end(), Some(2));
821 }
822
823 #[test]
824 fn buffer_parse_empty_lines_preserved() {
825 let lines = vec![
826 "# Title".to_string(),
827 String::new(),
828 "paragraph".to_string(),
829 ];
830 let parsed = ParsedBuffer::parse(&lines).lines;
831 assert_eq!(parsed.len(), 3);
832 assert_eq!(parsed[1].elements.len(), 0);
833 assert_eq!(parsed[1].content_vis.len(), 0);
834 }
835
836 #[test]
837 fn buffer_parse_ordered_nested_list() {
838 let lines = vec!["1. first".to_string(), " 1. nested".to_string()];
839 let parsed = ParsedBuffer::parse(&lines).lines;
840 assert_eq!(parsed[0].list_sigil_end(), Some(3));
841 assert_eq!(parsed[1].list_sigil_end(), Some(7));
842 }
843
844 #[test]
845 fn buffer_parse_setext_h1_spans_two_rows() {
846 let lines = vec!["My Heading".to_string(), "==========".to_string()];
852 let parsed = ParsedBuffer::parse(&lines).lines;
853 assert!(
854 parsed[0]
855 .elements
856 .iter()
857 .any(|e| e.kind == ElementKind::HeadingH1),
858 "setext underline must tag row 0 as HeadingH1"
859 );
860 assert!(
861 parsed[1]
862 .elements
863 .iter()
864 .any(|e| e.kind == ElementKind::HeadingH1),
865 "setext underline must tag row 1 as HeadingH1"
866 );
867 assert!(
869 parsed[1].content_vis.iter().all(|v| !v),
870 "setext underline row has no content"
871 );
872 }
873
874 #[test]
875 fn buffer_parse_multiline_blockquote() {
876 let lines = vec!["> first line".to_string(), "> second line".to_string()];
879 let parsed = ParsedBuffer::parse(&lines).lines;
880 assert!(
881 parsed[0]
882 .elements
883 .iter()
884 .any(|e| e.kind == ElementKind::Blockquote),
885 "row 0 must tag as Blockquote"
886 );
887 assert!(
888 parsed[1]
889 .elements
890 .iter()
891 .any(|e| e.kind == ElementKind::Blockquote),
892 "row 1 must tag as Blockquote"
893 );
894 }
895
896 #[test]
897 fn parse_line_emits_label_for_hashtag() {
898 let line = "see #rust later";
899 let parsed = ParsedLine::parse(line);
900 let label = parsed
901 .elements
902 .iter()
903 .find(|e| matches!(e.kind, ElementKind::Label));
904 assert!(
905 label.is_some(),
906 "expected Label element: {:?}",
907 parsed.elements
908 );
909 let l = label.unwrap();
910 let span: String = line
911 .chars()
912 .skip(l.start_char)
913 .take(l.end_char - l.start_char)
914 .collect();
915 assert_eq!(span, "#rust");
916 }
917
918 #[test]
919 fn parse_line_skips_label_inside_inline_code() {
920 let parsed = ParsedLine::parse("use `#foo` here");
921 let has_label = parsed
922 .elements
923 .iter()
924 .any(|e| matches!(e.kind, ElementKind::Label));
925 assert!(!has_label, "should not emit Label inside inline code");
926 }
927
928 #[test]
931 fn parse_line_skips_label_inside_markdown_link() {
932 let parsed = ParsedLine::parse("[see docs](#section) and #real");
933 let labels: Vec<_> = parsed
934 .elements
935 .iter()
936 .filter(|e| matches!(e.kind, ElementKind::Label))
937 .collect();
938 assert_eq!(
939 labels.len(),
940 1,
941 "only #real should be a label, not #section in the link"
942 );
943 let l = labels[0];
944 let span: String = "[see docs](#section) and #real"
945 .chars()
946 .skip(l.start_char)
947 .take(l.end_char - l.start_char)
948 .collect();
949 assert_eq!(span, "#real");
950 }
951
952 #[test]
953 fn parse_line_skips_label_inside_link_display_text() {
954 let parsed = ParsedLine::parse("[#todo](notes/project.md)");
955 let has_label = parsed
956 .elements
957 .iter()
958 .any(|e| matches!(e.kind, ElementKind::Label));
959 assert!(
960 !has_label,
961 "hashtag inside link display text should not become Label"
962 );
963 }
964
965 #[test]
966 fn parse_line_skips_label_after_label_char() {
967 let parsed = ParsedLine::parse("foo#bar baz");
968 let has_label = parsed
969 .elements
970 .iter()
971 .any(|e| matches!(e.kind, ElementKind::Label));
972 assert!(
973 !has_label,
974 "word#tag should not emit Label without word boundary"
975 );
976 }
977
978 #[test]
979 fn parse_line_skips_label_for_double_hash() {
980 let parsed = ParsedLine::parse("##draft");
984 let has_label = parsed
985 .elements
986 .iter()
987 .any(|e| matches!(e.kind, ElementKind::Label));
988 assert!(!has_label, "##draft should not emit Label");
989 }
990
991 #[test]
992 fn parse_line_skips_label_for_adjacent_hash_run() {
993 let parsed = ParsedLine::parse("#tag#more");
997 let labels: Vec<_> = parsed
998 .elements
999 .iter()
1000 .filter(|e| matches!(e.kind, ElementKind::Label))
1001 .collect();
1002 assert!(
1003 labels.is_empty(),
1004 "#tag#more should not emit Label, got {:?}",
1005 labels
1006 );
1007 }
1008
1009 #[test]
1010 fn parse_buffer_skips_label_inside_fenced_block() {
1011 let buffer = vec![
1012 "before".to_string(),
1013 "```".to_string(),
1014 "#inside".to_string(),
1015 "```".to_string(),
1016 "after #outside".to_string(),
1017 ];
1018 let lines = ParsedBuffer::parse(&buffer).lines;
1019 let inside_labels: Vec<_> = lines[2]
1020 .elements
1021 .iter()
1022 .filter(|e| matches!(e.kind, ElementKind::Label))
1023 .collect();
1024 assert!(
1025 inside_labels.is_empty(),
1026 "no Label emitted for hashtags in fenced blocks"
1027 );
1028
1029 let outside_labels: Vec<_> = lines[4]
1030 .elements
1031 .iter()
1032 .filter(|e| matches!(e.kind, ElementKind::Label))
1033 .collect();
1034 assert_eq!(outside_labels.len(), 1, "#outside still extracted");
1035 }
1036
1037 #[test]
1038 fn parse_range_full_equals_parse() {
1039 let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "**bold**".into()];
1040 let full = ParsedBuffer::parse(&lines);
1041 let range_full = ParsedBuffer::parse_range(&lines, 0..lines.len());
1042 assert_eq!(full.lines.len(), range_full.lines.len());
1043 assert_eq!(full.kinds, range_full.kinds);
1044 for (a, b) in full.lines.iter().zip(range_full.lines.iter()) {
1045 assert_eq!(a.content_vis, b.content_vis);
1046 assert_eq!(a.elements.len(), b.elements.len());
1047 }
1048 }
1049
1050 #[test]
1051 fn parse_range_paragraph_only_slice() {
1052 let lines: Vec<String> = vec![
1053 "intro paragraph".into(),
1054 "".into(),
1055 "middle line".into(),
1056 "".into(),
1057 "outro".into(),
1058 ];
1059 let slice = ParsedBuffer::parse_range(&lines, 2..3);
1060 assert_eq!(slice.lines.len(), 1);
1061 assert_eq!(slice.kinds, vec![LineConstructKind::Plain]);
1062 }
1063
1064 #[test]
1065 fn splice_replaces_range() {
1066 let mut pb = ParsedBuffer::parse(&["alpha".into(), "beta".into(), "gamma".into()]);
1067 let replacement = ParsedBuffer::parse(&["BETA-NEW".into()]);
1068 let replacement_kind = replacement.kinds[0];
1069 pb.splice(1..2, replacement);
1070 assert_eq!(pb.lines.len(), 3);
1071 assert_eq!(pb.kinds.len(), 3);
1072 assert_eq!(
1073 pb.kinds[1], replacement_kind,
1074 "replacement landed at the wrong index"
1075 );
1076 }
1077
1078 #[cfg(debug_assertions)]
1079 #[test]
1080 #[should_panic(expected = "splice")]
1081 fn splice_panics_on_length_mismatch_in_debug() {
1082 let mut pb = ParsedBuffer::parse(&["a".into(), "b".into()]);
1083 let too_short = ParsedBuffer::parse(&["X".into()]);
1084 pb.splice(0..2, too_short);
1085 }
1086
1087 #[test]
1098 fn lazy_depth_blockquote_closes_at_first_blank() {
1099 let lines: Vec<String> = vec!["> a".into(), "".into(), "".into(), "x".into()];
1100 let pb = ParsedBuffer::parse(&lines);
1101 assert_eq!(
1102 pb.lazy_depth,
1103 vec![1, 0, 0, 0],
1104 "blockquote closes at first blank per CommonMark ยง5.1; got {:?}",
1105 pb.lazy_depth,
1106 );
1107 }
1108
1109 #[test]
1115 fn lazy_depth_indented_code_across_blanks() {
1116 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1117 let pb = ParsedBuffer::parse(&lines);
1118 assert_eq!(
1119 pb.lazy_depth,
1120 vec![1, 1, 1],
1121 "indented code multi-chunk should keep lazy_depth > 0 across the blank \
1122 AND through the last content row; got {:?}",
1123 pb.lazy_depth,
1124 );
1125 }
1126
1127 #[test]
1131 fn lazy_depth_fenced_code_does_not_count() {
1132 let lines: Vec<String> = vec!["```".into(), "x".into(), "```".into(), "".into()];
1133 let pb = ParsedBuffer::parse(&lines);
1134 assert_eq!(
1135 pb.lazy_depth,
1136 vec![0, 0, 0, 0],
1137 "fenced code is not lazy-continuable; got {:?}",
1138 pb.lazy_depth,
1139 );
1140 }
1141
1142 #[test]
1153 fn lazy_depth_blockquote_with_trailing_blank_drops_at_blank() {
1154 let lines: Vec<String> = vec!["> a".into(), "".into()];
1155 let pb = ParsedBuffer::parse(&lines);
1156 assert_eq!(
1157 pb.lazy_depth,
1158 vec![1, 0],
1159 "blockquote must close at the trailing blank; got {:?}",
1160 pb.lazy_depth,
1161 );
1162 assert!(
1163 pb.reset_boundaries.contains(&1),
1164 "the trailing blank at row 1 must be a reset boundary; got {:?}",
1165 pb.reset_boundaries,
1166 );
1167 }
1168
1169 #[test]
1180 fn boundaries_skip_rows_inside_lazy_block() {
1181 let lines: Vec<String> = vec![" code".into(), "".into(), " more".into()];
1182 let pb = ParsedBuffer::parse(&lines);
1183 assert_eq!(
1184 pb.reset_boundaries,
1185 vec![0, lines.len()],
1186 "no boundary should land on a blank row inside the open indented-code block; \
1187 got {:?}",
1188 pb.reset_boundaries,
1189 );
1190 }
1191}