1use crate::primitives::grammar::GrammarRegistry;
8use crate::primitives::highlight_engine::highlight_string;
9use crate::primitives::highlighter::HighlightSpan;
10use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
11use ratatui::style::{Color, Modifier, Style};
12
13fn is_space(ch: char) -> bool {
15 ch == ' ' || ch == '\u{00A0}'
16}
17
18fn hanging_indent_width(leading_spaces: usize, max_width: usize) -> usize {
21 if leading_spaces + 10 > max_width {
22 0
23 } else {
24 leading_spaces
25 }
26}
27
28fn count_leading_spaces(text: &str) -> usize {
30 text.chars().take_while(|&ch| is_space(ch)).count()
31}
32
33pub fn wrap_text_line(text: &str, max_width: usize) -> Vec<String> {
38 if max_width == 0 {
39 return vec![text.to_string()];
40 }
41
42 let indent_width = hanging_indent_width(count_leading_spaces(text), max_width);
43 let indent = " ".repeat(indent_width);
44
45 let mut result = Vec::new();
46 let mut current_line = String::new();
47 let mut current_width = 0;
48
49 for (word, word_width) in WordSplitter::new(text) {
50 if current_width + word_width <= max_width {
52 current_line.push_str(&word);
53 current_width += word_width;
54 continue;
55 }
56
57 if current_line.is_empty() {
59 for ch in word.chars() {
60 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
61 if current_width + ch_width > max_width && !current_line.is_empty() {
62 result.push(current_line);
63 current_line = indent.clone();
64 current_width = indent_width;
65 }
66 current_line.push(ch);
67 current_width += ch_width;
68 }
69 continue;
70 }
71
72 result.push(current_line);
74 let trimmed = word.trim_start_matches(is_space);
75 current_line = format!("{}{}", indent, trimmed);
76 current_width = indent_width + unicode_width::UnicodeWidthStr::width(trimmed);
77 }
78
79 if !current_line.is_empty() || result.is_empty() {
80 result.push(current_line);
81 }
82
83 result
84}
85
86pub fn wrap_text_lines(lines: &[String], max_width: usize) -> Vec<String> {
88 let mut result = Vec::new();
89 for line in lines {
90 if line.is_empty() {
91 result.push(String::new());
92 } else {
93 result.extend(wrap_text_line(line, max_width));
94 }
95 }
96 result
97}
98
99pub fn wrap_styled_lines(lines: &[StyledLine], max_width: usize) -> Vec<StyledLine> {
103 if max_width == 0 {
104 return lines.to_vec();
105 }
106
107 let mut result = Vec::new();
108
109 for line in lines {
110 let total_width: usize = line
111 .spans
112 .iter()
113 .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
114 .sum();
115
116 if total_width <= max_width {
117 result.push(line.clone());
118 continue;
119 }
120
121 let leading_spaces = {
123 let mut count = 0usize;
124 'outer: for span in &line.spans {
125 for ch in span.text.chars() {
126 if is_space(ch) {
127 count += 1;
128 } else {
129 break 'outer;
130 }
131 }
132 }
133 count
134 };
135 let indent_width = hanging_indent_width(leading_spaces, max_width);
136
137 let segments = flatten_styled_segments(&line.spans);
139
140 let mut current_line = StyledLine::new();
141 let mut current_width = 0;
142
143 for (segment, style, link_url) in segments {
144 let seg_width = unicode_width::UnicodeWidthStr::width(segment.as_str());
145
146 if current_width + seg_width <= max_width {
148 current_line.push_with_link(segment, style, link_url);
149 current_width += seg_width;
150 continue;
151 }
152
153 if current_width == 0 {
155 let mut remaining = segment.as_str();
156 while !remaining.is_empty() {
157 let available = max_width.saturating_sub(current_width);
158 if available == 0 {
159 result.push(current_line);
160 current_line = new_continuation_line(indent_width);
161 current_width = indent_width;
162 continue;
163 }
164
165 let (take, rest) = split_at_width(remaining, available);
166 current_line.push_with_link(take.to_string(), style, link_url.clone());
167 current_width += unicode_width::UnicodeWidthStr::width(take);
168 remaining = rest;
169 }
170 continue;
171 }
172
173 result.push(current_line);
175 current_line = new_continuation_line(indent_width);
176 let trimmed = segment.trim_start_matches(is_space);
179 let trimmed_width = unicode_width::UnicodeWidthStr::width(trimmed);
180 current_line.push_with_link(trimmed.to_string(), style, link_url);
181 current_width = indent_width + trimmed_width;
182 }
183
184 if !current_line.spans.is_empty() {
185 result.push(current_line);
186 }
187 }
188
189 result
190}
191
192fn new_continuation_line(indent_width: usize) -> StyledLine {
194 let mut line = StyledLine::new();
195 if indent_width > 0 {
196 line.push(" ".repeat(indent_width), Style::default());
197 }
198 line
199}
200
201fn split_at_width(text: &str, available: usize) -> (&str, &str) {
204 let mut take_chars = 0;
205 let mut take_width = 0;
206 for ch in text.chars() {
207 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
208 if take_width + w > available && take_chars > 0 {
209 break;
210 }
211 take_width += w;
212 take_chars += 1;
213 }
214 let byte_idx = text
215 .char_indices()
216 .nth(take_chars)
217 .map(|(i, _)| i)
218 .unwrap_or(text.len());
219 text.split_at(byte_idx)
220}
221
222fn flatten_styled_segments(spans: &[StyledSpan]) -> Vec<(String, Style, Option<String>)> {
224 let mut segments = Vec::new();
225 for span in spans {
226 for (word, _width) in WordSplitter::new(&span.text) {
227 segments.push((word, span.style, span.link_url.clone()));
228 }
229 }
230 segments
231}
232
233struct WordSplitter<'a> {
236 chars: std::iter::Peekable<std::str::Chars<'a>>,
237}
238
239impl<'a> WordSplitter<'a> {
240 fn new(text: &'a str) -> Self {
241 Self {
242 chars: text.chars().peekable(),
243 }
244 }
245}
246
247impl<'a> Iterator for WordSplitter<'a> {
248 type Item = (String, usize);
249
250 fn next(&mut self) -> Option<Self::Item> {
251 self.chars.peek()?;
252
253 let mut word = String::new();
254 let mut width = 0;
255
256 while let Some(&ch) = self.chars.peek() {
258 if !is_space(ch) {
259 break;
260 }
261 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
262 word.push(ch);
263 width += w;
264 self.chars.next();
265 }
266
267 while let Some(&ch) = self.chars.peek() {
269 if is_space(ch) {
270 break;
271 }
272 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
273 word.push(ch);
274 width += w;
275 self.chars.next();
276 }
277
278 if word.is_empty() {
279 None
280 } else {
281 Some((word, width))
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq)]
288pub struct StyledSpan {
289 pub text: String,
290 pub style: Style,
291 pub link_url: Option<String>,
293}
294
295#[derive(Debug, Clone, PartialEq)]
297pub struct StyledLine {
298 pub spans: Vec<StyledSpan>,
299}
300
301impl StyledLine {
302 pub fn new() -> Self {
303 Self { spans: Vec::new() }
304 }
305
306 pub fn push(&mut self, text: String, style: Style) {
307 self.spans.push(StyledSpan {
308 text,
309 style,
310 link_url: None,
311 });
312 }
313
314 pub fn push_with_link(&mut self, text: String, style: Style, link_url: Option<String>) {
316 self.spans.push(StyledSpan {
317 text,
318 style,
319 link_url,
320 });
321 }
322
323 pub fn link_at_column(&self, column: usize) -> Option<&str> {
326 let mut current_col = 0;
327 for span in &self.spans {
328 let span_width = unicode_width::UnicodeWidthStr::width(span.text.as_str());
329 if column >= current_col && column < current_col + span_width {
330 return span.link_url.as_deref();
332 }
333 current_col += span_width;
334 }
335 None
336 }
337
338 pub fn plain_text(&self) -> String {
340 self.spans.iter().map(|s| s.text.as_str()).collect()
341 }
342}
343
344impl Default for StyledLine {
345 fn default() -> Self {
346 Self::new()
347 }
348}
349
350fn highlight_code_to_styled_lines(
352 code: &str,
353 spans: &[HighlightSpan],
354 theme: &crate::view::theme::Theme,
355) -> Vec<StyledLine> {
356 let mut result = vec![StyledLine::new()];
357 let code_bg = theme.inline_code_bg;
358 let default_fg = theme.popup_text_fg;
363
364 let bytes = code.as_bytes();
365 let mut pos = 0;
366
367 for span in spans {
368 if span.range.start > pos {
370 let text = String::from_utf8_lossy(&bytes[pos..span.range.start]);
371 add_text_to_lines(
372 &mut result,
373 &text,
374 Style::default().fg(default_fg).bg(code_bg),
375 None,
376 );
377 }
378
379 let text = String::from_utf8_lossy(&bytes[span.range.start..span.range.end]);
381 add_text_to_lines(
382 &mut result,
383 &text,
384 Style::default().fg(span.color).bg(code_bg),
385 None,
386 );
387
388 pos = span.range.end;
389 }
390
391 if pos < bytes.len() {
393 let text = String::from_utf8_lossy(&bytes[pos..]);
394 add_text_to_lines(
395 &mut result,
396 &text,
397 Style::default().fg(default_fg).bg(code_bg),
398 None,
399 );
400 }
401
402 result
403}
404
405fn add_text_to_lines(
409 lines: &mut Vec<StyledLine>,
410 text: &str,
411 style: Style,
412 link_url: Option<String>,
413) {
414 for (i, part) in text.split('\n').enumerate() {
415 if i > 0 {
416 lines.push(StyledLine::new());
417 }
418 if !part.is_empty() {
419 if let Some(line) = lines.last_mut() {
420 line.push_with_link(part.to_string(), style, link_url.clone());
421 }
422 }
423 }
424}
425
426fn preserve_leading_whitespace(text: &str) -> String {
431 text.lines()
432 .map(|line| {
433 let indent = line.len() - line.trim_start_matches(' ').len();
434 if indent > 0 {
435 format!("{}{}", "\u{00A0}".repeat(indent), &line[indent..])
436 } else {
437 line.to_string()
438 }
439 })
440 .collect::<Vec<_>>()
441 .join("\n")
442}
443
444pub fn parse_markdown(
449 text: &str,
450 theme: &crate::view::theme::Theme,
451 registry: Option<&GrammarRegistry>,
452) -> Vec<StyledLine> {
453 let preserved = preserve_leading_whitespace(text);
456
457 let mut options = Options::empty();
458 options.insert(Options::ENABLE_STRIKETHROUGH);
459
460 let parser = Parser::new_ext(&preserved, options);
461 let mut lines: Vec<StyledLine> = vec![StyledLine::new()];
462
463 let mut style_stack: Vec<Style> = vec![Style::default().fg(theme.popup_text_fg)];
469 let mut in_code_block = false;
470 let mut code_block_lang = String::new();
471 let mut current_link_url: Option<String> = None;
473
474 for event in parser {
475 match event {
476 Event::Start(tag) => {
477 match tag {
478 Tag::Strong => {
479 let current = *style_stack.last().unwrap_or(&Style::default());
480 style_stack.push(current.add_modifier(Modifier::BOLD));
481 }
482 Tag::Emphasis => {
483 let current = *style_stack.last().unwrap_or(&Style::default());
484 style_stack.push(current.add_modifier(Modifier::ITALIC));
485 }
486 Tag::Strikethrough => {
487 let current = *style_stack.last().unwrap_or(&Style::default());
488 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
489 }
490 Tag::CodeBlock(kind) => {
491 in_code_block = true;
492 code_block_lang = match kind {
493 pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
494 pulldown_cmark::CodeBlockKind::Indented => String::new(),
495 };
496 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
498 lines.push(StyledLine::new());
499 }
500 }
501 Tag::Heading { .. } => {
502 let current = *style_stack.last().unwrap_or(&Style::default());
503 style_stack
504 .push(current.add_modifier(Modifier::BOLD).fg(theme.help_key_fg));
505 }
506 Tag::Link { dest_url, .. } => {
507 let current = *style_stack.last().unwrap_or(&Style::default());
508 style_stack
509 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
510 current_link_url = Some(dest_url.to_string());
512 }
513 Tag::Image { .. } => {
514 let current = *style_stack.last().unwrap_or(&Style::default());
515 style_stack
516 .push(current.add_modifier(Modifier::UNDERLINED).fg(Color::Cyan));
517 }
518 Tag::List(_) | Tag::Item => {
519 if !lines.last().map(|l| l.spans.is_empty()).unwrap_or(true) {
521 lines.push(StyledLine::new());
522 }
523 }
524 Tag::Paragraph => {
525 let has_prior_content = lines.iter().any(|l| !l.spans.is_empty());
528 if has_prior_content {
529 lines.push(StyledLine::new());
530 }
531 }
532 _ => {}
533 }
534 }
535 Event::End(tag_end) => {
536 match tag_end {
537 TagEnd::Strong
538 | TagEnd::Emphasis
539 | TagEnd::Strikethrough
540 | TagEnd::Heading(_)
541 | TagEnd::Image => {
542 style_stack.pop();
543 }
544 TagEnd::Link => {
545 style_stack.pop();
546 current_link_url = None;
548 }
549 TagEnd::CodeBlock => {
550 in_code_block = false;
551 code_block_lang.clear();
552 lines.push(StyledLine::new());
554 }
555 TagEnd::Paragraph => {
556 lines.push(StyledLine::new());
558 }
559 TagEnd::Item => {
560 }
562 _ => {}
563 }
564 }
565 Event::Text(text) => {
566 if in_code_block {
567 let spans = if let Some(reg) = registry {
569 if !code_block_lang.is_empty() {
570 let s = highlight_string(&text, &code_block_lang, reg, theme);
571 let highlighted_bytes: usize =
573 s.iter().map(|span| span.range.end - span.range.start).sum();
574 let non_ws_bytes =
575 text.bytes().filter(|b| !b.is_ascii_whitespace()).count();
576 let good_coverage =
577 non_ws_bytes == 0 || highlighted_bytes * 5 >= non_ws_bytes;
578 if good_coverage {
579 s
580 } else {
581 Vec::new()
582 }
583 } else {
584 Vec::new()
585 }
586 } else {
587 Vec::new()
588 };
589
590 if !spans.is_empty() {
591 let highlighted_lines =
592 highlight_code_to_styled_lines(&text, &spans, theme);
593 for (i, styled_line) in highlighted_lines.into_iter().enumerate() {
594 if i > 0 {
595 lines.push(StyledLine::new());
596 }
597 if let Some(current_line) = lines.last_mut() {
599 for span in styled_line.spans {
600 current_line.push(span.text, span.style);
601 }
602 }
603 }
604 } else {
605 let code_style = Style::default()
609 .fg(theme.popup_text_fg)
610 .bg(theme.inline_code_bg);
611 add_text_to_lines(&mut lines, &text, code_style, None);
612 }
613 } else {
614 let current_style = *style_stack.last().unwrap_or(&Style::default());
615 add_text_to_lines(&mut lines, &text, current_style, current_link_url.clone());
616 }
617 }
618 Event::Code(code) => {
619 let style = Style::default()
623 .fg(theme.popup_text_fg)
624 .bg(theme.inline_code_bg);
625 if let Some(line) = lines.last_mut() {
626 line.push(code.to_string(), style);
627 }
628 }
629 Event::SoftBreak => {
630 lines.push(StyledLine::new());
634 }
635 Event::HardBreak => {
636 lines.push(StyledLine::new());
638 }
639 Event::Rule => {
640 lines.push(StyledLine::new());
642 if let Some(line) = lines.last_mut() {
643 line.push("─".repeat(40), Style::default().fg(Color::DarkGray));
644 }
645 lines.push(StyledLine::new());
646 }
647 _ => {}
648 }
649 }
650
651 while lines.last().map(|l| l.spans.is_empty()).unwrap_or(false) {
653 lines.pop();
654 }
655
656 lines
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::view::theme;
663 use crate::view::theme::Theme;
664
665 fn has_modifier(line: &StyledLine, modifier: Modifier) -> bool {
666 line.spans
667 .iter()
668 .any(|s| s.style.add_modifier.contains(modifier))
669 }
670
671 #[test]
672 fn test_plain_text() {
673 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
674 let lines = parse_markdown("Hello world", &theme, None);
675
676 assert_eq!(lines.len(), 1);
677 assert_eq!(lines[0].plain_text(), "Hello world");
678 }
679
680 #[test]
681 fn test_bold_text() {
682 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
683 let lines = parse_markdown("This is **bold** text", &theme, None);
684
685 assert_eq!(lines.len(), 1);
686 assert_eq!(lines[0].plain_text(), "This is bold text");
687
688 let bold_span = lines[0].spans.iter().find(|s| s.text == "bold");
690 assert!(bold_span.is_some(), "Should have a 'bold' span");
691 assert!(
692 bold_span
693 .unwrap()
694 .style
695 .add_modifier
696 .contains(Modifier::BOLD),
697 "Bold span should have BOLD modifier"
698 );
699 }
700
701 #[test]
702 fn test_italic_text() {
703 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
704 let lines = parse_markdown("This is *italic* text", &theme, None);
705
706 assert_eq!(lines.len(), 1);
707 assert_eq!(lines[0].plain_text(), "This is italic text");
708
709 let italic_span = lines[0].spans.iter().find(|s| s.text == "italic");
710 assert!(italic_span.is_some(), "Should have an 'italic' span");
711 assert!(
712 italic_span
713 .unwrap()
714 .style
715 .add_modifier
716 .contains(Modifier::ITALIC),
717 "Italic span should have ITALIC modifier"
718 );
719 }
720
721 #[test]
722 fn test_strikethrough_text() {
723 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
724 let lines = parse_markdown("This is ~~deleted~~ text", &theme, None);
725
726 assert_eq!(lines.len(), 1);
727 assert_eq!(lines[0].plain_text(), "This is deleted text");
728
729 let strike_span = lines[0].spans.iter().find(|s| s.text == "deleted");
730 assert!(strike_span.is_some(), "Should have a 'deleted' span");
731 assert!(
732 strike_span
733 .unwrap()
734 .style
735 .add_modifier
736 .contains(Modifier::CROSSED_OUT),
737 "Strikethrough span should have CROSSED_OUT modifier"
738 );
739 }
740
741 #[test]
742 fn test_inline_code() {
743 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
744 let lines = parse_markdown("Use `println!` to print", &theme, None);
745
746 assert_eq!(lines.len(), 1);
747 assert_eq!(lines[0].plain_text(), "Use println! to print");
749
750 let code_span = lines[0].spans.iter().find(|s| s.text.contains("println"));
752 assert!(code_span.is_some(), "Should have a code span");
753 assert!(
754 code_span.unwrap().style.bg.is_some(),
755 "Inline code should have background color"
756 );
757 }
758
759 #[test]
766 fn test_body_text_uses_popup_text_fg() {
767 let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
768 let lines = parse_markdown("Create a new string object.", &theme, None);
769 let span = lines[0]
770 .spans
771 .iter()
772 .find(|s| s.text.contains("string"))
773 .expect("body text span");
774 assert_eq!(span.style.fg, Some(theme.popup_text_fg));
775 }
776
777 #[test]
784 fn test_inline_code_uses_popup_text_fg() {
785 let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
786 assert_ne!(
787 theme.popup_text_fg, theme.help_key_fg,
788 "precondition: light theme distinguishes popup_text_fg from help_key_fg"
789 );
790
791 let lines = parse_markdown("Use `println!` to print", &theme, None);
792 let code_span = lines[0]
793 .spans
794 .iter()
795 .find(|s| s.text.contains("println"))
796 .expect("inline code span");
797 assert_eq!(code_span.style.fg, Some(theme.popup_text_fg));
798 }
799
800 #[test]
804 fn test_unknown_language_code_block_uses_popup_text_fg() {
805 let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
806 assert_ne!(theme.popup_text_fg, theme.help_key_fg);
807
808 let lines = parse_markdown("```\nplain text\n```", &theme, None);
809 let code_line = lines
810 .iter()
811 .find(|l| l.plain_text().contains("plain text"))
812 .expect("code block line");
813 let span = code_line
814 .spans
815 .iter()
816 .find(|s| s.text.contains("plain"))
817 .expect("code span");
818 assert_eq!(span.style.fg, Some(theme.popup_text_fg));
819 }
820
821 #[test]
822 fn test_code_block() {
823 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
824 let lines = parse_markdown("```rust\nfn main() {}\n```", &theme, None);
825
826 let code_line = lines.iter().find(|l| l.plain_text().contains("fn"));
828 assert!(code_line.is_some(), "Should have code block content");
829
830 let has_bg = code_line
833 .unwrap()
834 .spans
835 .iter()
836 .any(|s| s.style.bg.is_some());
837 assert!(has_bg, "Code block should have background color");
838 }
839
840 #[test]
841 fn test_code_block_syntax_highlighting() {
842 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
843 let registry =
844 GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
845 let markdown = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```";
847 let lines = parse_markdown(markdown, &theme, Some(®istry));
848
849 assert!(!lines.is_empty(), "Should have parsed lines");
851
852 let mut colors_used = std::collections::HashSet::new();
854 for line in &lines {
855 for span in &line.spans {
856 if let Some(fg) = span.style.fg {
857 colors_used.insert(format!("{:?}", fg));
858 }
859 }
860 }
861
862 assert!(
865 colors_used.len() > 1,
866 "Code block should have multiple colors for syntax highlighting, got: {:?}",
867 colors_used
868 );
869
870 let all_text: String = lines
872 .iter()
873 .map(|l| l.plain_text())
874 .collect::<Vec<_>>()
875 .join("");
876 assert!(all_text.contains("fn"), "Should contain 'fn' keyword");
877 assert!(all_text.contains("main"), "Should contain 'main'");
878 assert!(all_text.contains("println"), "Should contain 'println'");
879 }
880
881 #[test]
882 fn test_code_block_unknown_language_fallback() {
883 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
884 let markdown = "```unknownlang\nsome code here\n```";
886 let lines = parse_markdown(markdown, &theme, None);
887
888 assert!(!lines.is_empty(), "Should have parsed lines");
890
891 let all_text: String = lines
893 .iter()
894 .map(|l| l.plain_text())
895 .collect::<Vec<_>>()
896 .join("");
897 assert!(
898 all_text.contains("some code here"),
899 "Should contain the code"
900 );
901
902 let code_line = lines.iter().find(|l| l.plain_text().contains("some code"));
904 if let Some(line) = code_line {
905 for span in &line.spans {
906 assert!(span.style.bg.is_some(), "Code should have background color");
907 }
908 }
909 }
910
911 #[test]
912 fn test_heading() {
913 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
914 let lines = parse_markdown("# Heading\n\nContent", &theme, None);
915
916 let heading_line = &lines[0];
918 assert!(
919 has_modifier(heading_line, Modifier::BOLD),
920 "Heading should be bold"
921 );
922 assert_eq!(heading_line.plain_text(), "Heading");
923 }
924
925 #[test]
926 fn test_link() {
927 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
928 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
929
930 assert_eq!(lines.len(), 1);
931 assert_eq!(lines[0].plain_text(), "Click here for more");
932
933 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
935 assert!(link_span.is_some(), "Should have 'here' span");
936 let style = link_span.unwrap().style;
937 assert!(
938 style.add_modifier.contains(Modifier::UNDERLINED),
939 "Link should be underlined"
940 );
941 assert_eq!(style.fg, Some(Color::Cyan), "Link should be cyan");
942 }
943
944 #[test]
945 fn test_link_url_stored() {
946 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
947 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
948
949 assert_eq!(lines.len(), 1);
950
951 let link_span = lines[0].spans.iter().find(|s| s.text == "here");
953 assert!(link_span.is_some(), "Should have 'here' span");
954 assert_eq!(
955 link_span.unwrap().link_url,
956 Some("https://example.com".to_string()),
957 "Link span should store the URL"
958 );
959
960 let click_span = lines[0].spans.iter().find(|s| s.text == "Click ");
962 assert!(click_span.is_some(), "Should have 'Click ' span");
963 assert_eq!(
964 click_span.unwrap().link_url,
965 None,
966 "Non-link span should not have URL"
967 );
968 }
969
970 #[test]
971 fn test_link_at_column() {
972 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
973 let lines = parse_markdown("Click [here](https://example.com) for more", &theme, None);
974
975 assert_eq!(lines.len(), 1);
976 let line = &lines[0];
977
978 assert_eq!(
981 line.link_at_column(0),
982 None,
983 "Column 0 should not be a link"
984 );
985 assert_eq!(
986 line.link_at_column(5),
987 None,
988 "Column 5 should not be a link"
989 );
990
991 assert_eq!(
993 line.link_at_column(6),
994 Some("https://example.com"),
995 "Column 6 should be the link"
996 );
997 assert_eq!(
998 line.link_at_column(9),
999 Some("https://example.com"),
1000 "Column 9 should be the link"
1001 );
1002
1003 assert_eq!(
1005 line.link_at_column(10),
1006 None,
1007 "Column 10 should not be a link"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_unordered_list() {
1013 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1014 let lines = parse_markdown("- Item 1\n- Item 2\n- Item 3", &theme, None);
1015
1016 assert!(lines.len() >= 3, "Should have at least 3 lines for 3 items");
1018
1019 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
1020 assert!(all_text.contains("Item 1"), "Should contain Item 1");
1021 assert!(all_text.contains("Item 2"), "Should contain Item 2");
1022 assert!(all_text.contains("Item 3"), "Should contain Item 3");
1023 }
1024
1025 #[test]
1026 fn test_paragraph_separation() {
1027 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1028 let lines = parse_markdown("First paragraph.\n\nSecond paragraph.", &theme, None);
1029
1030 assert_eq!(
1032 lines.len(),
1033 3,
1034 "Should have 3 lines (para, blank, para), got: {:?}",
1035 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
1036 );
1037
1038 assert_eq!(lines[0].plain_text(), "First paragraph.");
1039 assert!(
1040 lines[1].spans.is_empty(),
1041 "Second line should be empty (paragraph break)"
1042 );
1043 assert_eq!(lines[2].plain_text(), "Second paragraph.");
1044 }
1045
1046 #[test]
1047 fn test_soft_break_becomes_newline() {
1048 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1049 let lines = parse_markdown("Line one\nLine two", &theme, None);
1051
1052 assert!(
1054 lines.len() >= 2,
1055 "Soft break should create separate lines, got {} lines",
1056 lines.len()
1057 );
1058 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
1059 assert!(
1060 all_text.contains("one") && all_text.contains("two"),
1061 "Should contain both lines"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_hard_break() {
1067 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1068 let lines = parse_markdown("Line one \nLine two", &theme, None);
1070
1071 assert!(lines.len() >= 2, "Hard break should create multiple lines");
1073 }
1074
1075 #[test]
1076 fn test_horizontal_rule() {
1077 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1078 let lines = parse_markdown("Above\n\n---\n\nBelow", &theme, None);
1079
1080 let has_rule = lines.iter().any(|l| l.plain_text().contains("─"));
1082 assert!(has_rule, "Should contain horizontal rule character");
1083 }
1084
1085 #[test]
1086 fn test_nested_formatting() {
1087 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1088 let lines = parse_markdown("This is ***bold and italic*** text", &theme, None);
1089
1090 assert_eq!(lines.len(), 1);
1091
1092 let nested_span = lines[0].spans.iter().find(|s| s.text == "bold and italic");
1094 assert!(nested_span.is_some(), "Should have nested formatted span");
1095
1096 let style = nested_span.unwrap().style;
1097 assert!(
1098 style.add_modifier.contains(Modifier::BOLD),
1099 "Should be bold"
1100 );
1101 assert!(
1102 style.add_modifier.contains(Modifier::ITALIC),
1103 "Should be italic"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_lsp_hover_docstring() {
1109 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1111 let markdown = "```python\n(class) Path\n```\n\nPurePath subclass that can make system calls.\n\nPath represents a filesystem path.";
1112
1113 let lines = parse_markdown(markdown, &theme, None);
1114
1115 assert!(lines.len() >= 3, "Should have multiple sections");
1117
1118 let code_line = lines.iter().find(|l| l.plain_text().contains("Path"));
1120 assert!(code_line.is_some(), "Should have code block with Path");
1121
1122 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
1124 assert!(
1125 all_text.contains("PurePath subclass"),
1126 "Should contain docstring"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_python_docstring_formatting() {
1132 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1134 let markdown = "Keyword Arguments:\n - prog -- The name\n - usage -- A usage message";
1135 let lines = parse_markdown(markdown, &theme, None);
1136
1137 assert!(
1139 lines.len() >= 3,
1140 "Should have multiple lines for keyword args list, got {} lines: {:?}",
1141 lines.len(),
1142 lines.iter().map(|l| l.plain_text()).collect::<Vec<_>>()
1143 );
1144 }
1145
1146 #[test]
1147 fn test_empty_input() {
1148 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1149 let lines = parse_markdown("", &theme, None);
1150
1151 assert!(
1153 lines.is_empty() || (lines.len() == 1 && lines[0].spans.is_empty()),
1154 "Empty input should produce empty output"
1155 );
1156 }
1157
1158 #[test]
1159 fn test_only_whitespace() {
1160 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1161 let lines = parse_markdown(" \n\n ", &theme, None);
1162
1163 for line in &lines {
1165 let text = line.plain_text();
1166 assert!(
1167 text.trim().is_empty(),
1168 "Whitespace-only input should not produce content"
1169 );
1170 }
1171 }
1172
1173 #[test]
1176 fn test_wrap_text_line_at_word_boundaries() {
1177 let text = "Path represents a filesystem path but unlike PurePath also offers methods";
1179 let wrapped = wrap_text_line(text, 30);
1180
1181 for (i, line) in wrapped.iter().enumerate() {
1183 if !line.is_empty() {
1185 assert!(
1186 !line.starts_with(' '),
1187 "Line {} should not start with space: {:?}",
1188 i,
1189 line
1190 );
1191 }
1192
1193 let line_width = unicode_width::UnicodeWidthStr::width(line.as_str());
1195 assert!(
1196 line_width <= 30,
1197 "Line {} exceeds max width: {} > 30, content: {:?}",
1198 i,
1199 line_width,
1200 line
1201 );
1202 }
1203
1204 let original_words: Vec<&str> = text.split_whitespace().collect();
1207 let wrapped_words: Vec<&str> = wrapped
1208 .iter()
1209 .flat_map(|line| line.split_whitespace())
1210 .collect();
1211 assert_eq!(
1212 original_words, wrapped_words,
1213 "Words should be preserved without breaking mid-word"
1214 );
1215
1216 assert_eq!(
1218 wrapped[0], "Path represents a filesystem",
1219 "First line should break at word boundary"
1220 );
1221 assert_eq!(
1222 wrapped[1], "path but unlike PurePath also",
1223 "Second line should contain next words (30 chars fits)"
1224 );
1225 assert_eq!(
1226 wrapped[2], "offers methods",
1227 "Third line should contain remaining words"
1228 );
1229 }
1230
1231 #[test]
1232 fn test_wrap_text_line_long_word() {
1233 let text = "supercalifragilisticexpialidocious";
1235 let wrapped = wrap_text_line(text, 10);
1236
1237 assert!(
1238 wrapped.len() > 1,
1239 "Long word should be split into multiple lines"
1240 );
1241
1242 for line in &wrapped {
1244 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1245 assert!(width <= 10, "Line should not exceed max width: {}", line);
1246 }
1247
1248 let rejoined: String = wrapped.join("");
1250 assert_eq!(rejoined, text, "Content should be preserved");
1251 }
1252
1253 #[test]
1254 fn test_wrap_text_line_empty() {
1255 let wrapped = wrap_text_line("", 30);
1256 assert_eq!(wrapped.len(), 1);
1257 assert_eq!(wrapped[0], "");
1258 }
1259
1260 #[test]
1261 fn test_wrap_text_line_fits() {
1262 let text = "Short text";
1263 let wrapped = wrap_text_line(text, 30);
1264 assert_eq!(wrapped.len(), 1);
1265 assert_eq!(wrapped[0], text);
1266 }
1267
1268 #[test]
1269 fn test_wrap_styled_lines_long_hover_content() {
1270 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1272
1273 let long_text = "def very_long_function_name(param1: str, param2: int, param3: float, param4: list, param5: dict) -> tuple[str, int, float]";
1275 let markdown = format!("```python\n{}\n```", long_text);
1276
1277 let lines = parse_markdown(&markdown, &theme, None);
1278
1279 assert!(!lines.is_empty(), "Should have parsed lines");
1281
1282 let wrapped = wrap_styled_lines(&lines, 40);
1284
1285 assert!(
1287 wrapped.len() > lines.len(),
1288 "Long line should wrap into multiple lines. Original: {}, Wrapped: {}",
1289 lines.len(),
1290 wrapped.len()
1291 );
1292
1293 for (i, line) in wrapped.iter().enumerate() {
1295 let line_width: usize = line
1296 .spans
1297 .iter()
1298 .map(|s| unicode_width::UnicodeWidthStr::width(s.text.as_str()))
1299 .sum();
1300 assert!(
1301 line_width <= 40,
1302 "Wrapped line {} exceeds max width: {} > 40, content: {:?}",
1303 i,
1304 line_width,
1305 line.spans
1306 .iter()
1307 .map(|s| s.text.as_str())
1308 .collect::<Vec<_>>()
1309 );
1310 }
1311
1312 let original_text: String = lines
1314 .iter()
1315 .flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
1316 .collect();
1317 let wrapped_text: String = wrapped
1318 .iter()
1319 .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect::<String>())
1320 .collect::<Vec<_>>()
1321 .join(" ");
1322 assert_eq!(
1323 original_text, wrapped_text,
1324 "Content should be preserved after wrapping (with spaces at line joins)"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_wrap_styled_lines_preserves_style() {
1330 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1331 let lines = parse_markdown("**bold text that is quite long**", &theme, None);
1332
1333 let wrapped = wrap_styled_lines(&lines, 15);
1334
1335 for line in &wrapped {
1337 for span in &line.spans {
1338 if !span.text.trim().is_empty() {
1339 assert!(
1340 span.style.add_modifier.contains(Modifier::BOLD),
1341 "Style should be preserved after wrapping: {:?}",
1342 span.text
1343 );
1344 }
1345 }
1346 }
1347 }
1348
1349 #[test]
1350 fn test_wrap_text_lines_multiple() {
1351 let lines = vec![
1352 "Short".to_string(),
1353 "This is a longer line that needs wrapping".to_string(),
1354 "".to_string(),
1355 "Another line".to_string(),
1356 ];
1357
1358 let wrapped = wrap_text_lines(&lines, 20);
1359
1360 assert!(
1362 wrapped.iter().any(|l| l.is_empty()),
1363 "Should preserve empty lines"
1364 );
1365
1366 for line in &wrapped {
1368 let width = unicode_width::UnicodeWidthStr::width(line.as_str());
1369 assert!(width <= 20, "Line exceeds max width: {}", line);
1370 }
1371 }
1372
1373 #[test]
1374 fn test_signature_help_doc_indent_preserved() {
1375 let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
1378 let content = "(*values: object, sep: str) -> None\n\n> *values\n\n---\n\nPrints the values to a stream.\n\nsep\n\n string inserted between values, default a space.\n\nend\n\n string appended after the last value, default a newline.";
1379
1380 let lines = parse_markdown(content, &theme, None);
1381 let texts: Vec<String> = lines.iter().map(|l| l.plain_text()).collect();
1382 eprintln!("[TEST] Parsed markdown lines:");
1383 for (i, t) in texts.iter().enumerate() {
1384 eprintln!(" [{}] {:?}", i, t);
1385 }
1386
1387 let desc_line = texts
1389 .iter()
1390 .find(|t| t.contains("string appended"))
1391 .expect("Should find 'string appended' line");
1392 eprintln!("[TEST] desc_line: {:?}", desc_line);
1393
1394 let wrapped = wrap_styled_lines(&lines, 40);
1396 let wrapped_texts: Vec<String> = wrapped.iter().map(|l| l.plain_text()).collect();
1397 eprintln!("[TEST] Wrapped lines:");
1398 for (i, t) in wrapped_texts.iter().enumerate() {
1399 eprintln!(" [{}] {:?}", i, t);
1400 }
1401
1402 let desc_idx = wrapped_texts
1404 .iter()
1405 .position(|t| t.contains("string appended"))
1406 .expect("Should find 'string appended' line in wrapped output");
1407 assert!(
1408 desc_idx + 1 < wrapped_texts.len(),
1409 "Line should have wrapped, but didn't. Lines: {:?}",
1410 wrapped_texts
1411 );
1412 let continuation = &wrapped_texts[desc_idx + 1];
1413 eprintln!("[TEST] continuation: {:?}", continuation);
1414
1415 let orig_indent = count_leading_spaces(desc_line);
1417 let cont_indent = count_leading_spaces(continuation);
1418 eprintln!(
1419 "[TEST] orig_indent={}, cont_indent={}",
1420 orig_indent, cont_indent
1421 );
1422 assert_eq!(
1423 cont_indent, orig_indent,
1424 "Continuation line should have same indent as original"
1425 );
1426 }
1427}